backend_gtk3.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. import functools
  2. import logging
  3. import os
  4. from pathlib import Path
  5. import matplotlib as mpl
  6. from matplotlib import _api, backend_tools, cbook
  7. from matplotlib.backend_bases import (
  8. ToolContainerBase, CloseEvent, KeyEvent, LocationEvent, MouseEvent,
  9. ResizeEvent)
  10. try:
  11. import gi
  12. except ImportError as err:
  13. raise ImportError("The GTK3 backends require PyGObject") from err
  14. try:
  15. # :raises ValueError: If module/version is already loaded, already
  16. # required, or unavailable.
  17. gi.require_version("Gtk", "3.0")
  18. except ValueError as e:
  19. # in this case we want to re-raise as ImportError so the
  20. # auto-backend selection logic correctly skips.
  21. raise ImportError(e) from e
  22. from gi.repository import Gio, GLib, GObject, Gtk, Gdk
  23. from . import _backend_gtk
  24. from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611
  25. _BackendGTK, _FigureCanvasGTK, _FigureManagerGTK, _NavigationToolbar2GTK,
  26. TimerGTK as TimerGTK3,
  27. )
  28. _log = logging.getLogger(__name__)
  29. @functools.cache
  30. def _mpl_to_gtk_cursor(mpl_cursor):
  31. return Gdk.Cursor.new_from_name(
  32. Gdk.Display.get_default(),
  33. _backend_gtk.mpl_to_gtk_cursor_name(mpl_cursor))
  34. class FigureCanvasGTK3(_FigureCanvasGTK, Gtk.DrawingArea):
  35. required_interactive_framework = "gtk3"
  36. manager_class = _api.classproperty(lambda cls: FigureManagerGTK3)
  37. # Setting this as a static constant prevents
  38. # this resulting expression from leaking
  39. event_mask = (Gdk.EventMask.BUTTON_PRESS_MASK
  40. | Gdk.EventMask.BUTTON_RELEASE_MASK
  41. | Gdk.EventMask.EXPOSURE_MASK
  42. | Gdk.EventMask.KEY_PRESS_MASK
  43. | Gdk.EventMask.KEY_RELEASE_MASK
  44. | Gdk.EventMask.ENTER_NOTIFY_MASK
  45. | Gdk.EventMask.LEAVE_NOTIFY_MASK
  46. | Gdk.EventMask.POINTER_MOTION_MASK
  47. | Gdk.EventMask.SCROLL_MASK)
  48. def __init__(self, figure=None):
  49. super().__init__(figure=figure)
  50. self._idle_draw_id = 0
  51. self._rubberband_rect = None
  52. self.connect('scroll_event', self.scroll_event)
  53. self.connect('button_press_event', self.button_press_event)
  54. self.connect('button_release_event', self.button_release_event)
  55. self.connect('configure_event', self.configure_event)
  56. self.connect('screen-changed', self._update_device_pixel_ratio)
  57. self.connect('notify::scale-factor', self._update_device_pixel_ratio)
  58. self.connect('draw', self.on_draw_event)
  59. self.connect('draw', self._post_draw)
  60. self.connect('key_press_event', self.key_press_event)
  61. self.connect('key_release_event', self.key_release_event)
  62. self.connect('motion_notify_event', self.motion_notify_event)
  63. self.connect('enter_notify_event', self.enter_notify_event)
  64. self.connect('leave_notify_event', self.leave_notify_event)
  65. self.connect('size_allocate', self.size_allocate)
  66. self.set_events(self.__class__.event_mask)
  67. self.set_can_focus(True)
  68. css = Gtk.CssProvider()
  69. css.load_from_data(b".matplotlib-canvas { background-color: white; }")
  70. style_ctx = self.get_style_context()
  71. style_ctx.add_provider(css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
  72. style_ctx.add_class("matplotlib-canvas")
  73. def destroy(self):
  74. CloseEvent("close_event", self)._process()
  75. def set_cursor(self, cursor):
  76. # docstring inherited
  77. window = self.get_property("window")
  78. if window is not None:
  79. window.set_cursor(_mpl_to_gtk_cursor(cursor))
  80. context = GLib.MainContext.default()
  81. context.iteration(True)
  82. def _mpl_coords(self, event=None):
  83. """
  84. Convert the position of a GTK event, or of the current cursor position
  85. if *event* is None, to Matplotlib coordinates.
  86. GTK use logical pixels, but the figure is scaled to physical pixels for
  87. rendering. Transform to physical pixels so that all of the down-stream
  88. transforms work as expected.
  89. Also, the origin is different and needs to be corrected.
  90. """
  91. if event is None:
  92. window = self.get_window()
  93. t, x, y, state = window.get_device_position(
  94. window.get_display().get_device_manager().get_client_pointer())
  95. else:
  96. x, y = event.x, event.y
  97. x = x * self.device_pixel_ratio
  98. # flip y so y=0 is bottom of canvas
  99. y = self.figure.bbox.height - y * self.device_pixel_ratio
  100. return x, y
  101. def scroll_event(self, widget, event):
  102. step = 1 if event.direction == Gdk.ScrollDirection.UP else -1
  103. MouseEvent("scroll_event", self,
  104. *self._mpl_coords(event), step=step,
  105. modifiers=self._mpl_modifiers(event.state),
  106. guiEvent=event)._process()
  107. return False # finish event propagation?
  108. def button_press_event(self, widget, event):
  109. MouseEvent("button_press_event", self,
  110. *self._mpl_coords(event), event.button,
  111. modifiers=self._mpl_modifiers(event.state),
  112. guiEvent=event)._process()
  113. return False # finish event propagation?
  114. def button_release_event(self, widget, event):
  115. MouseEvent("button_release_event", self,
  116. *self._mpl_coords(event), event.button,
  117. modifiers=self._mpl_modifiers(event.state),
  118. guiEvent=event)._process()
  119. return False # finish event propagation?
  120. def key_press_event(self, widget, event):
  121. KeyEvent("key_press_event", self,
  122. self._get_key(event), *self._mpl_coords(),
  123. guiEvent=event)._process()
  124. return True # stop event propagation
  125. def key_release_event(self, widget, event):
  126. KeyEvent("key_release_event", self,
  127. self._get_key(event), *self._mpl_coords(),
  128. guiEvent=event)._process()
  129. return True # stop event propagation
  130. def motion_notify_event(self, widget, event):
  131. MouseEvent("motion_notify_event", self, *self._mpl_coords(event),
  132. modifiers=self._mpl_modifiers(event.state),
  133. guiEvent=event)._process()
  134. return False # finish event propagation?
  135. def enter_notify_event(self, widget, event):
  136. gtk_mods = Gdk.Keymap.get_for_display(
  137. self.get_display()).get_modifier_state()
  138. LocationEvent("figure_enter_event", self, *self._mpl_coords(event),
  139. modifiers=self._mpl_modifiers(gtk_mods),
  140. guiEvent=event)._process()
  141. def leave_notify_event(self, widget, event):
  142. gtk_mods = Gdk.Keymap.get_for_display(
  143. self.get_display()).get_modifier_state()
  144. LocationEvent("figure_leave_event", self, *self._mpl_coords(event),
  145. modifiers=self._mpl_modifiers(gtk_mods),
  146. guiEvent=event)._process()
  147. def size_allocate(self, widget, allocation):
  148. dpival = self.figure.dpi
  149. winch = allocation.width * self.device_pixel_ratio / dpival
  150. hinch = allocation.height * self.device_pixel_ratio / dpival
  151. self.figure.set_size_inches(winch, hinch, forward=False)
  152. ResizeEvent("resize_event", self)._process()
  153. self.draw_idle()
  154. @staticmethod
  155. def _mpl_modifiers(event_state, *, exclude=None):
  156. modifiers = [
  157. ("ctrl", Gdk.ModifierType.CONTROL_MASK, "control"),
  158. ("alt", Gdk.ModifierType.MOD1_MASK, "alt"),
  159. ("shift", Gdk.ModifierType.SHIFT_MASK, "shift"),
  160. ("super", Gdk.ModifierType.MOD4_MASK, "super"),
  161. ]
  162. return [name for name, mask, key in modifiers
  163. if exclude != key and event_state & mask]
  164. def _get_key(self, event):
  165. unikey = chr(Gdk.keyval_to_unicode(event.keyval))
  166. key = cbook._unikey_or_keysym_to_mplkey(
  167. unikey, Gdk.keyval_name(event.keyval))
  168. mods = self._mpl_modifiers(event.state, exclude=key)
  169. if "shift" in mods and unikey.isprintable():
  170. mods.remove("shift")
  171. return "+".join([*mods, key])
  172. def _update_device_pixel_ratio(self, *args, **kwargs):
  173. # We need to be careful in cases with mixed resolution displays if
  174. # device_pixel_ratio changes.
  175. if self._set_device_pixel_ratio(self.get_scale_factor()):
  176. # The easiest way to resize the canvas is to emit a resize event
  177. # since we implement all the logic for resizing the canvas for that
  178. # event.
  179. self.queue_resize()
  180. self.queue_draw()
  181. def configure_event(self, widget, event):
  182. if widget.get_property("window") is None:
  183. return
  184. w = event.width * self.device_pixel_ratio
  185. h = event.height * self.device_pixel_ratio
  186. if w < 3 or h < 3:
  187. return # empty fig
  188. # resize the figure (in inches)
  189. dpi = self.figure.dpi
  190. self.figure.set_size_inches(w / dpi, h / dpi, forward=False)
  191. return False # finish event propagation?
  192. def _draw_rubberband(self, rect):
  193. self._rubberband_rect = rect
  194. # TODO: Only update the rubberband area.
  195. self.queue_draw()
  196. def _post_draw(self, widget, ctx):
  197. if self._rubberband_rect is None:
  198. return
  199. x0, y0, w, h = (dim / self.device_pixel_ratio
  200. for dim in self._rubberband_rect)
  201. x1 = x0 + w
  202. y1 = y0 + h
  203. # Draw the lines from x0, y0 towards x1, y1 so that the
  204. # dashes don't "jump" when moving the zoom box.
  205. ctx.move_to(x0, y0)
  206. ctx.line_to(x0, y1)
  207. ctx.move_to(x0, y0)
  208. ctx.line_to(x1, y0)
  209. ctx.move_to(x0, y1)
  210. ctx.line_to(x1, y1)
  211. ctx.move_to(x1, y0)
  212. ctx.line_to(x1, y1)
  213. ctx.set_antialias(1)
  214. ctx.set_line_width(1)
  215. ctx.set_dash((3, 3), 0)
  216. ctx.set_source_rgb(0, 0, 0)
  217. ctx.stroke_preserve()
  218. ctx.set_dash((3, 3), 3)
  219. ctx.set_source_rgb(1, 1, 1)
  220. ctx.stroke()
  221. def on_draw_event(self, widget, ctx):
  222. # to be overwritten by GTK3Agg or GTK3Cairo
  223. pass
  224. def draw(self):
  225. # docstring inherited
  226. if self.is_drawable():
  227. self.queue_draw()
  228. def draw_idle(self):
  229. # docstring inherited
  230. if self._idle_draw_id != 0:
  231. return
  232. def idle_draw(*args):
  233. try:
  234. self.draw()
  235. finally:
  236. self._idle_draw_id = 0
  237. return False
  238. self._idle_draw_id = GLib.idle_add(idle_draw)
  239. def flush_events(self):
  240. # docstring inherited
  241. context = GLib.MainContext.default()
  242. while context.pending():
  243. context.iteration(True)
  244. class NavigationToolbar2GTK3(_NavigationToolbar2GTK, Gtk.Toolbar):
  245. def __init__(self, canvas):
  246. GObject.GObject.__init__(self)
  247. self.set_style(Gtk.ToolbarStyle.ICONS)
  248. self._gtk_ids = {}
  249. for text, tooltip_text, image_file, callback in self.toolitems:
  250. if text is None:
  251. self.insert(Gtk.SeparatorToolItem(), -1)
  252. continue
  253. image = Gtk.Image.new_from_gicon(
  254. Gio.Icon.new_for_string(
  255. str(cbook._get_data_path('images',
  256. f'{image_file}-symbolic.svg'))),
  257. Gtk.IconSize.LARGE_TOOLBAR)
  258. self._gtk_ids[text] = button = (
  259. Gtk.ToggleToolButton() if callback in ['zoom', 'pan'] else
  260. Gtk.ToolButton())
  261. button.set_label(text)
  262. button.set_icon_widget(image)
  263. # Save the handler id, so that we can block it as needed.
  264. button._signal_handler = button.connect(
  265. 'clicked', getattr(self, callback))
  266. button.set_tooltip_text(tooltip_text)
  267. self.insert(button, -1)
  268. # This filler item ensures the toolbar is always at least two text
  269. # lines high. Otherwise the canvas gets redrawn as the mouse hovers
  270. # over images because those use two-line messages which resize the
  271. # toolbar.
  272. toolitem = Gtk.ToolItem()
  273. self.insert(toolitem, -1)
  274. label = Gtk.Label()
  275. label.set_markup(
  276. '<small>\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}</small>')
  277. toolitem.set_expand(True) # Push real message to the right.
  278. toolitem.add(label)
  279. toolitem = Gtk.ToolItem()
  280. self.insert(toolitem, -1)
  281. self.message = Gtk.Label()
  282. self.message.set_justify(Gtk.Justification.RIGHT)
  283. toolitem.add(self.message)
  284. self.show_all()
  285. _NavigationToolbar2GTK.__init__(self, canvas)
  286. def save_figure(self, *args):
  287. dialog = Gtk.FileChooserDialog(
  288. title="Save the figure",
  289. parent=self.canvas.get_toplevel(),
  290. action=Gtk.FileChooserAction.SAVE,
  291. buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
  292. Gtk.STOCK_SAVE, Gtk.ResponseType.OK),
  293. )
  294. for name, fmts \
  295. in self.canvas.get_supported_filetypes_grouped().items():
  296. ff = Gtk.FileFilter()
  297. ff.set_name(name)
  298. for fmt in fmts:
  299. ff.add_pattern(f'*.{fmt}')
  300. dialog.add_filter(ff)
  301. if self.canvas.get_default_filetype() in fmts:
  302. dialog.set_filter(ff)
  303. @functools.partial(dialog.connect, "notify::filter")
  304. def on_notify_filter(*args):
  305. name = dialog.get_filter().get_name()
  306. fmt = self.canvas.get_supported_filetypes_grouped()[name][0]
  307. dialog.set_current_name(
  308. str(Path(dialog.get_current_name()).with_suffix(f'.{fmt}')))
  309. dialog.set_current_folder(mpl.rcParams["savefig.directory"])
  310. dialog.set_current_name(self.canvas.get_default_filename())
  311. dialog.set_do_overwrite_confirmation(True)
  312. response = dialog.run()
  313. fname = dialog.get_filename()
  314. ff = dialog.get_filter() # Doesn't autoadjust to filename :/
  315. fmt = self.canvas.get_supported_filetypes_grouped()[ff.get_name()][0]
  316. dialog.destroy()
  317. if response != Gtk.ResponseType.OK:
  318. return
  319. # Save dir for next time, unless empty str (which means use cwd).
  320. if mpl.rcParams['savefig.directory']:
  321. mpl.rcParams['savefig.directory'] = os.path.dirname(fname)
  322. try:
  323. self.canvas.figure.savefig(fname, format=fmt)
  324. except Exception as e:
  325. dialog = Gtk.MessageDialog(
  326. parent=self.canvas.get_toplevel(), message_format=str(e),
  327. type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK)
  328. dialog.run()
  329. dialog.destroy()
  330. class ToolbarGTK3(ToolContainerBase, Gtk.Box):
  331. _icon_extension = '-symbolic.svg'
  332. def __init__(self, toolmanager):
  333. ToolContainerBase.__init__(self, toolmanager)
  334. Gtk.Box.__init__(self)
  335. self.set_property('orientation', Gtk.Orientation.HORIZONTAL)
  336. self._message = Gtk.Label()
  337. self._message.set_justify(Gtk.Justification.RIGHT)
  338. self.pack_end(self._message, False, False, 0)
  339. self.show_all()
  340. self._groups = {}
  341. self._toolitems = {}
  342. def add_toolitem(self, name, group, position, image_file, description,
  343. toggle):
  344. if toggle:
  345. button = Gtk.ToggleToolButton()
  346. else:
  347. button = Gtk.ToolButton()
  348. button.set_label(name)
  349. if image_file is not None:
  350. image = Gtk.Image.new_from_gicon(
  351. Gio.Icon.new_for_string(image_file),
  352. Gtk.IconSize.LARGE_TOOLBAR)
  353. button.set_icon_widget(image)
  354. if position is None:
  355. position = -1
  356. self._add_button(button, group, position)
  357. signal = button.connect('clicked', self._call_tool, name)
  358. button.set_tooltip_text(description)
  359. button.show_all()
  360. self._toolitems.setdefault(name, [])
  361. self._toolitems[name].append((button, signal))
  362. def _add_button(self, button, group, position):
  363. if group not in self._groups:
  364. if self._groups:
  365. self._add_separator()
  366. toolbar = Gtk.Toolbar()
  367. toolbar.set_style(Gtk.ToolbarStyle.ICONS)
  368. self.pack_start(toolbar, False, False, 0)
  369. toolbar.show_all()
  370. self._groups[group] = toolbar
  371. self._groups[group].insert(button, position)
  372. def _call_tool(self, btn, name):
  373. self.trigger_tool(name)
  374. def toggle_toolitem(self, name, toggled):
  375. if name not in self._toolitems:
  376. return
  377. for toolitem, signal in self._toolitems[name]:
  378. toolitem.handler_block(signal)
  379. toolitem.set_active(toggled)
  380. toolitem.handler_unblock(signal)
  381. def remove_toolitem(self, name):
  382. if name not in self._toolitems:
  383. self.toolmanager.message_event(f'{name} not in toolbar', self)
  384. return
  385. for group in self._groups:
  386. for toolitem, _signal in self._toolitems[name]:
  387. if toolitem in self._groups[group]:
  388. self._groups[group].remove(toolitem)
  389. del self._toolitems[name]
  390. def _add_separator(self):
  391. sep = Gtk.Separator()
  392. sep.set_property("orientation", Gtk.Orientation.VERTICAL)
  393. self.pack_start(sep, False, True, 0)
  394. sep.show_all()
  395. def set_message(self, s):
  396. self._message.set_label(s)
  397. @backend_tools._register_tool_class(FigureCanvasGTK3)
  398. class SaveFigureGTK3(backend_tools.SaveFigureBase):
  399. def trigger(self, *args, **kwargs):
  400. NavigationToolbar2GTK3.save_figure(
  401. self._make_classic_style_pseudo_toolbar())
  402. @backend_tools._register_tool_class(FigureCanvasGTK3)
  403. class HelpGTK3(backend_tools.ToolHelpBase):
  404. def _normalize_shortcut(self, key):
  405. """
  406. Convert Matplotlib key presses to GTK+ accelerator identifiers.
  407. Related to `FigureCanvasGTK3._get_key`.
  408. """
  409. special = {
  410. 'backspace': 'BackSpace',
  411. 'pagedown': 'Page_Down',
  412. 'pageup': 'Page_Up',
  413. 'scroll_lock': 'Scroll_Lock',
  414. }
  415. parts = key.split('+')
  416. mods = ['<' + mod + '>' for mod in parts[:-1]]
  417. key = parts[-1]
  418. if key in special:
  419. key = special[key]
  420. elif len(key) > 1:
  421. key = key.capitalize()
  422. elif key.isupper():
  423. mods += ['<shift>']
  424. return ''.join(mods) + key
  425. def _is_valid_shortcut(self, key):
  426. """
  427. Check for a valid shortcut to be displayed.
  428. - GTK will never send 'cmd+' (see `FigureCanvasGTK3._get_key`).
  429. - The shortcut window only shows keyboard shortcuts, not mouse buttons.
  430. """
  431. return 'cmd+' not in key and not key.startswith('MouseButton.')
  432. def _show_shortcuts_window(self):
  433. section = Gtk.ShortcutsSection()
  434. for name, tool in sorted(self.toolmanager.tools.items()):
  435. if not tool.description:
  436. continue
  437. # Putting everything in a separate group allows GTK to
  438. # automatically split them into separate columns/pages, which is
  439. # useful because we have lots of shortcuts, some with many keys
  440. # that are very wide.
  441. group = Gtk.ShortcutsGroup()
  442. section.add(group)
  443. # A hack to remove the title since we have no group naming.
  444. group.forall(lambda widget, data: widget.set_visible(False), None)
  445. shortcut = Gtk.ShortcutsShortcut(
  446. accelerator=' '.join(
  447. self._normalize_shortcut(key)
  448. for key in self.toolmanager.get_tool_keymap(name)
  449. if self._is_valid_shortcut(key)),
  450. title=tool.name,
  451. subtitle=tool.description)
  452. group.add(shortcut)
  453. window = Gtk.ShortcutsWindow(
  454. title='Help',
  455. modal=True,
  456. transient_for=self._figure.canvas.get_toplevel())
  457. section.show() # Must be done explicitly before add!
  458. window.add(section)
  459. window.show_all()
  460. def _show_shortcuts_dialog(self):
  461. dialog = Gtk.MessageDialog(
  462. self._figure.canvas.get_toplevel(),
  463. 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, self._get_help_text(),
  464. title="Help")
  465. dialog.run()
  466. dialog.destroy()
  467. def trigger(self, *args):
  468. if Gtk.check_version(3, 20, 0) is None:
  469. self._show_shortcuts_window()
  470. else:
  471. self._show_shortcuts_dialog()
  472. @backend_tools._register_tool_class(FigureCanvasGTK3)
  473. class ToolCopyToClipboardGTK3(backend_tools.ToolCopyToClipboardBase):
  474. def trigger(self, *args, **kwargs):
  475. clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
  476. window = self.canvas.get_window()
  477. x, y, width, height = window.get_geometry()
  478. pb = Gdk.pixbuf_get_from_window(window, x, y, width, height)
  479. clipboard.set_image(pb)
  480. Toolbar = ToolbarGTK3
  481. backend_tools._register_tool_class(
  482. FigureCanvasGTK3, _backend_gtk.ConfigureSubplotsGTK)
  483. backend_tools._register_tool_class(
  484. FigureCanvasGTK3, _backend_gtk.RubberbandGTK)
  485. class FigureManagerGTK3(_FigureManagerGTK):
  486. _toolbar2_class = NavigationToolbar2GTK3
  487. _toolmanager_toolbar_class = ToolbarGTK3
  488. @_BackendGTK.export
  489. class _BackendGTK3(_BackendGTK):
  490. FigureCanvas = FigureCanvasGTK3
  491. FigureManager = FigureManagerGTK3