backend_gtk4.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. import functools
  2. import io
  3. import os
  4. import matplotlib as mpl
  5. from matplotlib import _api, backend_tools, cbook
  6. from matplotlib.backend_bases import (
  7. ToolContainerBase, KeyEvent, LocationEvent, MouseEvent, ResizeEvent,
  8. CloseEvent)
  9. try:
  10. import gi
  11. except ImportError as err:
  12. raise ImportError("The GTK4 backends require PyGObject") from err
  13. try:
  14. # :raises ValueError: If module/version is already loaded, already
  15. # required, or unavailable.
  16. gi.require_version("Gtk", "4.0")
  17. except ValueError as e:
  18. # in this case we want to re-raise as ImportError so the
  19. # auto-backend selection logic correctly skips.
  20. raise ImportError(e) from e
  21. from gi.repository import Gio, GLib, Gtk, Gdk, GdkPixbuf
  22. from . import _backend_gtk
  23. from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611
  24. _BackendGTK, _FigureCanvasGTK, _FigureManagerGTK, _NavigationToolbar2GTK,
  25. TimerGTK as TimerGTK4,
  26. )
  27. class FigureCanvasGTK4(_FigureCanvasGTK, Gtk.DrawingArea):
  28. required_interactive_framework = "gtk4"
  29. supports_blit = False
  30. manager_class = _api.classproperty(lambda cls: FigureManagerGTK4)
  31. _context_is_scaled = False
  32. def __init__(self, figure=None):
  33. super().__init__(figure=figure)
  34. self.set_hexpand(True)
  35. self.set_vexpand(True)
  36. self._idle_draw_id = 0
  37. self._rubberband_rect = None
  38. self.set_draw_func(self._draw_func)
  39. self.connect('resize', self.resize_event)
  40. self.connect('notify::scale-factor', self._update_device_pixel_ratio)
  41. click = Gtk.GestureClick()
  42. click.set_button(0) # All buttons.
  43. click.connect('pressed', self.button_press_event)
  44. click.connect('released', self.button_release_event)
  45. self.add_controller(click)
  46. key = Gtk.EventControllerKey()
  47. key.connect('key-pressed', self.key_press_event)
  48. key.connect('key-released', self.key_release_event)
  49. self.add_controller(key)
  50. motion = Gtk.EventControllerMotion()
  51. motion.connect('motion', self.motion_notify_event)
  52. motion.connect('enter', self.enter_notify_event)
  53. motion.connect('leave', self.leave_notify_event)
  54. self.add_controller(motion)
  55. scroll = Gtk.EventControllerScroll.new(
  56. Gtk.EventControllerScrollFlags.VERTICAL)
  57. scroll.connect('scroll', self.scroll_event)
  58. self.add_controller(scroll)
  59. self.set_focusable(True)
  60. css = Gtk.CssProvider()
  61. style = '.matplotlib-canvas { background-color: white; }'
  62. if Gtk.check_version(4, 9, 3) is None:
  63. css.load_from_data(style, -1)
  64. else:
  65. css.load_from_data(style.encode('utf-8'))
  66. style_ctx = self.get_style_context()
  67. style_ctx.add_provider(css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
  68. style_ctx.add_class("matplotlib-canvas")
  69. def destroy(self):
  70. CloseEvent("close_event", self)._process()
  71. def set_cursor(self, cursor):
  72. # docstring inherited
  73. self.set_cursor_from_name(_backend_gtk.mpl_to_gtk_cursor_name(cursor))
  74. def _mpl_coords(self, xy=None):
  75. """
  76. Convert the *xy* position of a GTK event, or of the current cursor
  77. position if *xy* is None, to Matplotlib coordinates.
  78. GTK use logical pixels, but the figure is scaled to physical pixels for
  79. rendering. Transform to physical pixels so that all of the down-stream
  80. transforms work as expected.
  81. Also, the origin is different and needs to be corrected.
  82. """
  83. if xy is None:
  84. surface = self.get_native().get_surface()
  85. is_over, x, y, mask = surface.get_device_position(
  86. self.get_display().get_default_seat().get_pointer())
  87. else:
  88. x, y = xy
  89. x = x * self.device_pixel_ratio
  90. # flip y so y=0 is bottom of canvas
  91. y = self.figure.bbox.height - y * self.device_pixel_ratio
  92. return x, y
  93. def scroll_event(self, controller, dx, dy):
  94. MouseEvent(
  95. "scroll_event", self, *self._mpl_coords(), step=dy,
  96. modifiers=self._mpl_modifiers(controller),
  97. )._process()
  98. return True
  99. def button_press_event(self, controller, n_press, x, y):
  100. MouseEvent(
  101. "button_press_event", self, *self._mpl_coords((x, y)),
  102. controller.get_current_button(),
  103. modifiers=self._mpl_modifiers(controller),
  104. )._process()
  105. self.grab_focus()
  106. def button_release_event(self, controller, n_press, x, y):
  107. MouseEvent(
  108. "button_release_event", self, *self._mpl_coords((x, y)),
  109. controller.get_current_button(),
  110. modifiers=self._mpl_modifiers(controller),
  111. )._process()
  112. def key_press_event(self, controller, keyval, keycode, state):
  113. KeyEvent(
  114. "key_press_event", self, self._get_key(keyval, keycode, state),
  115. *self._mpl_coords(),
  116. )._process()
  117. return True
  118. def key_release_event(self, controller, keyval, keycode, state):
  119. KeyEvent(
  120. "key_release_event", self, self._get_key(keyval, keycode, state),
  121. *self._mpl_coords(),
  122. )._process()
  123. return True
  124. def motion_notify_event(self, controller, x, y):
  125. MouseEvent(
  126. "motion_notify_event", self, *self._mpl_coords((x, y)),
  127. modifiers=self._mpl_modifiers(controller),
  128. )._process()
  129. def enter_notify_event(self, controller, x, y):
  130. LocationEvent(
  131. "figure_enter_event", self, *self._mpl_coords((x, y)),
  132. modifiers=self._mpl_modifiers(),
  133. )._process()
  134. def leave_notify_event(self, controller):
  135. LocationEvent(
  136. "figure_leave_event", self, *self._mpl_coords(),
  137. modifiers=self._mpl_modifiers(),
  138. )._process()
  139. def resize_event(self, area, width, height):
  140. self._update_device_pixel_ratio()
  141. dpi = self.figure.dpi
  142. winch = width * self.device_pixel_ratio / dpi
  143. hinch = height * self.device_pixel_ratio / dpi
  144. self.figure.set_size_inches(winch, hinch, forward=False)
  145. ResizeEvent("resize_event", self)._process()
  146. self.draw_idle()
  147. def _mpl_modifiers(self, controller=None):
  148. if controller is None:
  149. surface = self.get_native().get_surface()
  150. is_over, x, y, event_state = surface.get_device_position(
  151. self.get_display().get_default_seat().get_pointer())
  152. else:
  153. event_state = controller.get_current_event_state()
  154. mod_table = [
  155. ("ctrl", Gdk.ModifierType.CONTROL_MASK),
  156. ("alt", Gdk.ModifierType.ALT_MASK),
  157. ("shift", Gdk.ModifierType.SHIFT_MASK),
  158. ("super", Gdk.ModifierType.SUPER_MASK),
  159. ]
  160. return [name for name, mask in mod_table if event_state & mask]
  161. def _get_key(self, keyval, keycode, state):
  162. unikey = chr(Gdk.keyval_to_unicode(keyval))
  163. key = cbook._unikey_or_keysym_to_mplkey(
  164. unikey,
  165. Gdk.keyval_name(keyval))
  166. modifiers = [
  167. ("ctrl", Gdk.ModifierType.CONTROL_MASK, "control"),
  168. ("alt", Gdk.ModifierType.ALT_MASK, "alt"),
  169. ("shift", Gdk.ModifierType.SHIFT_MASK, "shift"),
  170. ("super", Gdk.ModifierType.SUPER_MASK, "super"),
  171. ]
  172. mods = [
  173. mod for mod, mask, mod_key in modifiers
  174. if (mod_key != key and state & mask
  175. and not (mod == "shift" and unikey.isprintable()))]
  176. return "+".join([*mods, key])
  177. def _update_device_pixel_ratio(self, *args, **kwargs):
  178. # We need to be careful in cases with mixed resolution displays if
  179. # device_pixel_ratio changes.
  180. if self._set_device_pixel_ratio(self.get_scale_factor()):
  181. self.draw()
  182. def _draw_rubberband(self, rect):
  183. self._rubberband_rect = rect
  184. # TODO: Only update the rubberband area.
  185. self.queue_draw()
  186. def _draw_func(self, drawing_area, ctx, width, height):
  187. self.on_draw_event(self, ctx)
  188. self._post_draw(self, ctx)
  189. def _post_draw(self, widget, ctx):
  190. if self._rubberband_rect is None:
  191. return
  192. lw = 1
  193. dash = 3
  194. if not self._context_is_scaled:
  195. x0, y0, w, h = (dim / self.device_pixel_ratio
  196. for dim in self._rubberband_rect)
  197. else:
  198. x0, y0, w, h = self._rubberband_rect
  199. lw *= self.device_pixel_ratio
  200. dash *= self.device_pixel_ratio
  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(lw)
  215. ctx.set_dash((dash, dash), 0)
  216. ctx.set_source_rgb(0, 0, 0)
  217. ctx.stroke_preserve()
  218. ctx.set_dash((dash, dash), dash)
  219. ctx.set_source_rgb(1, 1, 1)
  220. ctx.stroke()
  221. def on_draw_event(self, widget, ctx):
  222. # to be overwritten by GTK4Agg or GTK4Cairo
  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 NavigationToolbar2GTK4(_NavigationToolbar2GTK, Gtk.Box):
  245. def __init__(self, canvas):
  246. Gtk.Box.__init__(self)
  247. self.add_css_class('toolbar')
  248. self._gtk_ids = {}
  249. for text, tooltip_text, image_file, callback in self.toolitems:
  250. if text is None:
  251. self.append(Gtk.Separator())
  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. self._gtk_ids[text] = button = (
  258. Gtk.ToggleButton() if callback in ['zoom', 'pan'] else
  259. Gtk.Button())
  260. button.set_child(image)
  261. button.add_css_class('flat')
  262. button.add_css_class('image-button')
  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.append(button)
  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. label = Gtk.Label()
  273. label.set_markup(
  274. '<small>\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}</small>')
  275. label.set_hexpand(True) # Push real message to the right.
  276. self.append(label)
  277. self.message = Gtk.Label()
  278. self.message.set_justify(Gtk.Justification.RIGHT)
  279. self.append(self.message)
  280. _NavigationToolbar2GTK.__init__(self, canvas)
  281. def save_figure(self, *args):
  282. dialog = Gtk.FileChooserNative(
  283. title='Save the figure',
  284. transient_for=self.canvas.get_root(),
  285. action=Gtk.FileChooserAction.SAVE,
  286. modal=True)
  287. self._save_dialog = dialog # Must keep a reference.
  288. ff = Gtk.FileFilter()
  289. ff.set_name('All files')
  290. ff.add_pattern('*')
  291. dialog.add_filter(ff)
  292. dialog.set_filter(ff)
  293. formats = []
  294. default_format = None
  295. for i, (name, fmts) in enumerate(
  296. self.canvas.get_supported_filetypes_grouped().items()):
  297. ff = Gtk.FileFilter()
  298. ff.set_name(name)
  299. for fmt in fmts:
  300. ff.add_pattern(f'*.{fmt}')
  301. dialog.add_filter(ff)
  302. formats.append(name)
  303. if self.canvas.get_default_filetype() in fmts:
  304. default_format = i
  305. # Setting the choice doesn't always work, so make sure the default
  306. # format is first.
  307. formats = [formats[default_format], *formats[:default_format],
  308. *formats[default_format+1:]]
  309. dialog.add_choice('format', 'File format', formats, formats)
  310. dialog.set_choice('format', formats[default_format])
  311. dialog.set_current_folder(Gio.File.new_for_path(
  312. os.path.expanduser(mpl.rcParams['savefig.directory'])))
  313. dialog.set_current_name(self.canvas.get_default_filename())
  314. @functools.partial(dialog.connect, 'response')
  315. def on_response(dialog, response):
  316. file = dialog.get_file()
  317. fmt = dialog.get_choice('format')
  318. fmt = self.canvas.get_supported_filetypes_grouped()[fmt][0]
  319. dialog.destroy()
  320. self._save_dialog = None
  321. if response != Gtk.ResponseType.ACCEPT:
  322. return
  323. # Save dir for next time, unless empty str (which means use cwd).
  324. if mpl.rcParams['savefig.directory']:
  325. parent = file.get_parent()
  326. mpl.rcParams['savefig.directory'] = parent.get_path()
  327. try:
  328. self.canvas.figure.savefig(file.get_path(), format=fmt)
  329. except Exception as e:
  330. msg = Gtk.MessageDialog(
  331. transient_for=self.canvas.get_root(),
  332. message_type=Gtk.MessageType.ERROR,
  333. buttons=Gtk.ButtonsType.OK, modal=True,
  334. text=str(e))
  335. msg.show()
  336. dialog.show()
  337. class ToolbarGTK4(ToolContainerBase, Gtk.Box):
  338. _icon_extension = '-symbolic.svg'
  339. def __init__(self, toolmanager):
  340. ToolContainerBase.__init__(self, toolmanager)
  341. Gtk.Box.__init__(self)
  342. self.set_property('orientation', Gtk.Orientation.HORIZONTAL)
  343. # Tool items are created later, but must appear before the message.
  344. self._tool_box = Gtk.Box()
  345. self.append(self._tool_box)
  346. self._groups = {}
  347. self._toolitems = {}
  348. # This filler item ensures the toolbar is always at least two text
  349. # lines high. Otherwise the canvas gets redrawn as the mouse hovers
  350. # over images because those use two-line messages which resize the
  351. # toolbar.
  352. label = Gtk.Label()
  353. label.set_markup(
  354. '<small>\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}</small>')
  355. label.set_hexpand(True) # Push real message to the right.
  356. self.append(label)
  357. self._message = Gtk.Label()
  358. self._message.set_justify(Gtk.Justification.RIGHT)
  359. self.append(self._message)
  360. def add_toolitem(self, name, group, position, image_file, description,
  361. toggle):
  362. if toggle:
  363. button = Gtk.ToggleButton()
  364. else:
  365. button = Gtk.Button()
  366. button.set_label(name)
  367. button.add_css_class('flat')
  368. if image_file is not None:
  369. image = Gtk.Image.new_from_gicon(
  370. Gio.Icon.new_for_string(image_file))
  371. button.set_child(image)
  372. button.add_css_class('image-button')
  373. if position is None:
  374. position = -1
  375. self._add_button(button, group, position)
  376. signal = button.connect('clicked', self._call_tool, name)
  377. button.set_tooltip_text(description)
  378. self._toolitems.setdefault(name, [])
  379. self._toolitems[name].append((button, signal))
  380. def _find_child_at_position(self, group, position):
  381. children = [None]
  382. child = self._groups[group].get_first_child()
  383. while child is not None:
  384. children.append(child)
  385. child = child.get_next_sibling()
  386. return children[position]
  387. def _add_button(self, button, group, position):
  388. if group not in self._groups:
  389. if self._groups:
  390. self._add_separator()
  391. group_box = Gtk.Box()
  392. self._tool_box.append(group_box)
  393. self._groups[group] = group_box
  394. self._groups[group].insert_child_after(
  395. button, self._find_child_at_position(group, position))
  396. def _call_tool(self, btn, name):
  397. self.trigger_tool(name)
  398. def toggle_toolitem(self, name, toggled):
  399. if name not in self._toolitems:
  400. return
  401. for toolitem, signal in self._toolitems[name]:
  402. toolitem.handler_block(signal)
  403. toolitem.set_active(toggled)
  404. toolitem.handler_unblock(signal)
  405. def remove_toolitem(self, name):
  406. if name not in self._toolitems:
  407. self.toolmanager.message_event(f'{name} not in toolbar', self)
  408. return
  409. for group in self._groups:
  410. for toolitem, _signal in self._toolitems[name]:
  411. if toolitem in self._groups[group]:
  412. self._groups[group].remove(toolitem)
  413. del self._toolitems[name]
  414. def _add_separator(self):
  415. sep = Gtk.Separator()
  416. sep.set_property("orientation", Gtk.Orientation.VERTICAL)
  417. self._tool_box.append(sep)
  418. def set_message(self, s):
  419. self._message.set_label(s)
  420. @backend_tools._register_tool_class(FigureCanvasGTK4)
  421. class SaveFigureGTK4(backend_tools.SaveFigureBase):
  422. def trigger(self, *args, **kwargs):
  423. NavigationToolbar2GTK4.save_figure(
  424. self._make_classic_style_pseudo_toolbar())
  425. @backend_tools._register_tool_class(FigureCanvasGTK4)
  426. class HelpGTK4(backend_tools.ToolHelpBase):
  427. def _normalize_shortcut(self, key):
  428. """
  429. Convert Matplotlib key presses to GTK+ accelerator identifiers.
  430. Related to `FigureCanvasGTK4._get_key`.
  431. """
  432. special = {
  433. 'backspace': 'BackSpace',
  434. 'pagedown': 'Page_Down',
  435. 'pageup': 'Page_Up',
  436. 'scroll_lock': 'Scroll_Lock',
  437. }
  438. parts = key.split('+')
  439. mods = ['<' + mod + '>' for mod in parts[:-1]]
  440. key = parts[-1]
  441. if key in special:
  442. key = special[key]
  443. elif len(key) > 1:
  444. key = key.capitalize()
  445. elif key.isupper():
  446. mods += ['<shift>']
  447. return ''.join(mods) + key
  448. def _is_valid_shortcut(self, key):
  449. """
  450. Check for a valid shortcut to be displayed.
  451. - GTK will never send 'cmd+' (see `FigureCanvasGTK4._get_key`).
  452. - The shortcut window only shows keyboard shortcuts, not mouse buttons.
  453. """
  454. return 'cmd+' not in key and not key.startswith('MouseButton.')
  455. def trigger(self, *args):
  456. section = Gtk.ShortcutsSection()
  457. for name, tool in sorted(self.toolmanager.tools.items()):
  458. if not tool.description:
  459. continue
  460. # Putting everything in a separate group allows GTK to
  461. # automatically split them into separate columns/pages, which is
  462. # useful because we have lots of shortcuts, some with many keys
  463. # that are very wide.
  464. group = Gtk.ShortcutsGroup()
  465. section.append(group)
  466. # A hack to remove the title since we have no group naming.
  467. child = group.get_first_child()
  468. while child is not None:
  469. child.set_visible(False)
  470. child = child.get_next_sibling()
  471. shortcut = Gtk.ShortcutsShortcut(
  472. accelerator=' '.join(
  473. self._normalize_shortcut(key)
  474. for key in self.toolmanager.get_tool_keymap(name)
  475. if self._is_valid_shortcut(key)),
  476. title=tool.name,
  477. subtitle=tool.description)
  478. group.append(shortcut)
  479. window = Gtk.ShortcutsWindow(
  480. title='Help',
  481. modal=True,
  482. transient_for=self._figure.canvas.get_root())
  483. window.set_child(section)
  484. window.show()
  485. @backend_tools._register_tool_class(FigureCanvasGTK4)
  486. class ToolCopyToClipboardGTK4(backend_tools.ToolCopyToClipboardBase):
  487. def trigger(self, *args, **kwargs):
  488. with io.BytesIO() as f:
  489. self.canvas.print_rgba(f)
  490. w, h = self.canvas.get_width_height()
  491. pb = GdkPixbuf.Pixbuf.new_from_data(f.getbuffer(),
  492. GdkPixbuf.Colorspace.RGB, True,
  493. 8, w, h, w*4)
  494. clipboard = self.canvas.get_clipboard()
  495. clipboard.set(pb)
  496. backend_tools._register_tool_class(
  497. FigureCanvasGTK4, _backend_gtk.ConfigureSubplotsGTK)
  498. backend_tools._register_tool_class(
  499. FigureCanvasGTK4, _backend_gtk.RubberbandGTK)
  500. Toolbar = ToolbarGTK4
  501. class FigureManagerGTK4(_FigureManagerGTK):
  502. _toolbar2_class = NavigationToolbar2GTK4
  503. _toolmanager_toolbar_class = ToolbarGTK4
  504. @_BackendGTK.export
  505. class _BackendGTK4(_BackendGTK):
  506. FigureCanvas = FigureCanvasGTK4
  507. FigureManager = FigureManagerGTK4