_backend_gtk.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. """
  2. Common code for GTK3 and GTK4 backends.
  3. """
  4. import logging
  5. import sys
  6. import matplotlib as mpl
  7. from matplotlib import _api, backend_tools, cbook
  8. from matplotlib._pylab_helpers import Gcf
  9. from matplotlib.backend_bases import (
  10. _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2,
  11. TimerBase)
  12. from matplotlib.backend_tools import Cursors
  13. import gi
  14. # The GTK3/GTK4 backends will have already called `gi.require_version` to set
  15. # the desired GTK.
  16. from gi.repository import Gdk, Gio, GLib, Gtk
  17. try:
  18. gi.require_foreign("cairo")
  19. except ImportError as e:
  20. raise ImportError("Gtk-based backends require cairo") from e
  21. _log = logging.getLogger(__name__)
  22. _application = None # Placeholder
  23. def _shutdown_application(app):
  24. # The application might prematurely shut down if Ctrl-C'd out of IPython,
  25. # so close all windows.
  26. for win in app.get_windows():
  27. win.close()
  28. # The PyGObject wrapper incorrectly thinks that None is not allowed, or we
  29. # would call this:
  30. # Gio.Application.set_default(None)
  31. # Instead, we set this property and ignore default applications with it:
  32. app._created_by_matplotlib = True
  33. global _application
  34. _application = None
  35. def _create_application():
  36. global _application
  37. if _application is None:
  38. app = Gio.Application.get_default()
  39. if app is None or getattr(app, '_created_by_matplotlib', False):
  40. # display_is_valid returns False only if on Linux and neither X11
  41. # nor Wayland display can be opened.
  42. if not mpl._c_internal_utils.display_is_valid():
  43. raise RuntimeError('Invalid DISPLAY variable')
  44. _application = Gtk.Application.new('org.matplotlib.Matplotlib3',
  45. Gio.ApplicationFlags.NON_UNIQUE)
  46. # The activate signal must be connected, but we don't care for
  47. # handling it, since we don't do any remote processing.
  48. _application.connect('activate', lambda *args, **kwargs: None)
  49. _application.connect('shutdown', _shutdown_application)
  50. _application.register()
  51. cbook._setup_new_guiapp()
  52. else:
  53. _application = app
  54. return _application
  55. def mpl_to_gtk_cursor_name(mpl_cursor):
  56. return _api.check_getitem({
  57. Cursors.MOVE: "move",
  58. Cursors.HAND: "pointer",
  59. Cursors.POINTER: "default",
  60. Cursors.SELECT_REGION: "crosshair",
  61. Cursors.WAIT: "wait",
  62. Cursors.RESIZE_HORIZONTAL: "ew-resize",
  63. Cursors.RESIZE_VERTICAL: "ns-resize",
  64. }, cursor=mpl_cursor)
  65. class TimerGTK(TimerBase):
  66. """Subclass of `.TimerBase` using GTK timer events."""
  67. def __init__(self, *args, **kwargs):
  68. self._timer = None
  69. super().__init__(*args, **kwargs)
  70. def _timer_start(self):
  71. # Need to stop it, otherwise we potentially leak a timer id that will
  72. # never be stopped.
  73. self._timer_stop()
  74. self._timer = GLib.timeout_add(self._interval, self._on_timer)
  75. def _timer_stop(self):
  76. if self._timer is not None:
  77. GLib.source_remove(self._timer)
  78. self._timer = None
  79. def _timer_set_interval(self):
  80. # Only stop and restart it if the timer has already been started.
  81. if self._timer is not None:
  82. self._timer_stop()
  83. self._timer_start()
  84. def _on_timer(self):
  85. super()._on_timer()
  86. # Gtk timeout_add() requires that the callback returns True if it
  87. # is to be called again.
  88. if self.callbacks and not self._single:
  89. return True
  90. else:
  91. self._timer = None
  92. return False
  93. class _FigureCanvasGTK(FigureCanvasBase):
  94. _timer_cls = TimerGTK
  95. class _FigureManagerGTK(FigureManagerBase):
  96. """
  97. Attributes
  98. ----------
  99. canvas : `FigureCanvas`
  100. The FigureCanvas instance
  101. num : int or str
  102. The Figure number
  103. toolbar : Gtk.Toolbar or Gtk.Box
  104. The toolbar
  105. vbox : Gtk.VBox
  106. The Gtk.VBox containing the canvas and toolbar
  107. window : Gtk.Window
  108. The Gtk.Window
  109. """
  110. def __init__(self, canvas, num):
  111. self._gtk_ver = gtk_ver = Gtk.get_major_version()
  112. app = _create_application()
  113. self.window = Gtk.Window()
  114. app.add_window(self.window)
  115. super().__init__(canvas, num)
  116. if gtk_ver == 3:
  117. icon_ext = "png" if sys.platform == "win32" else "svg"
  118. self.window.set_icon_from_file(
  119. str(cbook._get_data_path(f"images/matplotlib.{icon_ext}")))
  120. self.vbox = Gtk.Box()
  121. self.vbox.set_property("orientation", Gtk.Orientation.VERTICAL)
  122. if gtk_ver == 3:
  123. self.window.add(self.vbox)
  124. self.vbox.show()
  125. self.canvas.show()
  126. self.vbox.pack_start(self.canvas, True, True, 0)
  127. elif gtk_ver == 4:
  128. self.window.set_child(self.vbox)
  129. self.vbox.prepend(self.canvas)
  130. # calculate size for window
  131. w, h = self.canvas.get_width_height()
  132. if self.toolbar is not None:
  133. if gtk_ver == 3:
  134. self.toolbar.show()
  135. self.vbox.pack_end(self.toolbar, False, False, 0)
  136. elif gtk_ver == 4:
  137. sw = Gtk.ScrolledWindow(vscrollbar_policy=Gtk.PolicyType.NEVER)
  138. sw.set_child(self.toolbar)
  139. self.vbox.append(sw)
  140. min_size, nat_size = self.toolbar.get_preferred_size()
  141. h += nat_size.height
  142. self.window.set_default_size(w, h)
  143. self._destroying = False
  144. self.window.connect("destroy", lambda *args: Gcf.destroy(self))
  145. self.window.connect({3: "delete_event", 4: "close-request"}[gtk_ver],
  146. lambda *args: Gcf.destroy(self))
  147. if mpl.is_interactive():
  148. self.window.show()
  149. self.canvas.draw_idle()
  150. self.canvas.grab_focus()
  151. def destroy(self, *args):
  152. if self._destroying:
  153. # Otherwise, this can be called twice when the user presses 'q',
  154. # which calls Gcf.destroy(self), then this destroy(), then triggers
  155. # Gcf.destroy(self) once again via
  156. # `connect("destroy", lambda *args: Gcf.destroy(self))`.
  157. return
  158. self._destroying = True
  159. self.window.destroy()
  160. self.canvas.destroy()
  161. @classmethod
  162. def start_main_loop(cls):
  163. global _application
  164. if _application is None:
  165. return
  166. try:
  167. _application.run() # Quits when all added windows close.
  168. except KeyboardInterrupt:
  169. # Ensure all windows can process their close event from
  170. # _shutdown_application.
  171. context = GLib.MainContext.default()
  172. while context.pending():
  173. context.iteration(True)
  174. raise
  175. finally:
  176. # Running after quit is undefined, so create a new one next time.
  177. _application = None
  178. def show(self):
  179. # show the figure window
  180. self.window.show()
  181. self.canvas.draw()
  182. if mpl.rcParams["figure.raise_window"]:
  183. meth_name = {3: "get_window", 4: "get_surface"}[self._gtk_ver]
  184. if getattr(self.window, meth_name)():
  185. self.window.present()
  186. else:
  187. # If this is called by a callback early during init,
  188. # self.window (a GtkWindow) may not have an associated
  189. # low-level GdkWindow (on GTK3) or GdkSurface (on GTK4) yet,
  190. # and present() would crash.
  191. _api.warn_external("Cannot raise window yet to be setup")
  192. def full_screen_toggle(self):
  193. is_fullscreen = {
  194. 3: lambda w: (w.get_window().get_state()
  195. & Gdk.WindowState.FULLSCREEN),
  196. 4: lambda w: w.is_fullscreen(),
  197. }[self._gtk_ver]
  198. if is_fullscreen(self.window):
  199. self.window.unfullscreen()
  200. else:
  201. self.window.fullscreen()
  202. def get_window_title(self):
  203. return self.window.get_title()
  204. def set_window_title(self, title):
  205. self.window.set_title(title)
  206. def resize(self, width, height):
  207. width = int(width / self.canvas.device_pixel_ratio)
  208. height = int(height / self.canvas.device_pixel_ratio)
  209. if self.toolbar:
  210. min_size, nat_size = self.toolbar.get_preferred_size()
  211. height += nat_size.height
  212. canvas_size = self.canvas.get_allocation()
  213. if self._gtk_ver >= 4 or canvas_size.width == canvas_size.height == 1:
  214. # A canvas size of (1, 1) cannot exist in most cases, because
  215. # window decorations would prevent such a small window. This call
  216. # must be before the window has been mapped and widgets have been
  217. # sized, so just change the window's starting size.
  218. self.window.set_default_size(width, height)
  219. else:
  220. self.window.resize(width, height)
  221. class _NavigationToolbar2GTK(NavigationToolbar2):
  222. # Must be implemented in GTK3/GTK4 backends:
  223. # * __init__
  224. # * save_figure
  225. def set_message(self, s):
  226. escaped = GLib.markup_escape_text(s)
  227. self.message.set_markup(f'<small>{escaped}</small>')
  228. def draw_rubberband(self, event, x0, y0, x1, y1):
  229. height = self.canvas.figure.bbox.height
  230. y1 = height - y1
  231. y0 = height - y0
  232. rect = [int(val) for val in (x0, y0, x1 - x0, y1 - y0)]
  233. self.canvas._draw_rubberband(rect)
  234. def remove_rubberband(self):
  235. self.canvas._draw_rubberband(None)
  236. def _update_buttons_checked(self):
  237. for name, active in [("Pan", "PAN"), ("Zoom", "ZOOM")]:
  238. button = self._gtk_ids.get(name)
  239. if button:
  240. with button.handler_block(button._signal_handler):
  241. button.set_active(self.mode.name == active)
  242. def pan(self, *args):
  243. super().pan(*args)
  244. self._update_buttons_checked()
  245. def zoom(self, *args):
  246. super().zoom(*args)
  247. self._update_buttons_checked()
  248. def set_history_buttons(self):
  249. can_backward = self._nav_stack._pos > 0
  250. can_forward = self._nav_stack._pos < len(self._nav_stack) - 1
  251. if 'Back' in self._gtk_ids:
  252. self._gtk_ids['Back'].set_sensitive(can_backward)
  253. if 'Forward' in self._gtk_ids:
  254. self._gtk_ids['Forward'].set_sensitive(can_forward)
  255. class RubberbandGTK(backend_tools.RubberbandBase):
  256. def draw_rubberband(self, x0, y0, x1, y1):
  257. _NavigationToolbar2GTK.draw_rubberband(
  258. self._make_classic_style_pseudo_toolbar(), None, x0, y0, x1, y1)
  259. def remove_rubberband(self):
  260. _NavigationToolbar2GTK.remove_rubberband(
  261. self._make_classic_style_pseudo_toolbar())
  262. class ConfigureSubplotsGTK(backend_tools.ConfigureSubplotsBase):
  263. def trigger(self, *args):
  264. _NavigationToolbar2GTK.configure_subplots(self, None)
  265. class _BackendGTK(_Backend):
  266. backend_version = "{}.{}.{}".format(
  267. Gtk.get_major_version(),
  268. Gtk.get_minor_version(),
  269. Gtk.get_micro_version(),
  270. )
  271. mainloop = _FigureManagerGTK.start_main_loop