mirror of
https://github.com/kristoferssolo/School.git
synced 2025-10-21 20:10:38 +00:00
699 lines
24 KiB
Python
699 lines
24 KiB
Python
import functools
|
|
import io
|
|
import os
|
|
from pathlib import Path
|
|
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, ToolContainerBase)
|
|
from matplotlib.backend_tools import Cursors
|
|
from matplotlib.figure import Figure
|
|
from matplotlib.widgets import SubplotTool
|
|
|
|
try:
|
|
import gi
|
|
except ImportError as err:
|
|
raise ImportError("The GTK4 backends require PyGObject") from err
|
|
|
|
try:
|
|
# :raises ValueError: If module/version is already loaded, already
|
|
# required, or unavailable.
|
|
gi.require_version("Gtk", "4.0")
|
|
except ValueError as e:
|
|
# in this case we want to re-raise as ImportError so the
|
|
# auto-backend selection logic correctly skips.
|
|
raise ImportError from e
|
|
|
|
from gi.repository import Gio, GLib, GObject, Gtk, Gdk, GdkPixbuf
|
|
from ._backend_gtk import (
|
|
_create_application, _shutdown_application,
|
|
backend_version, _BackendGTK, _NavigationToolbar2GTK,
|
|
TimerGTK as TimerGTK4,
|
|
ConfigureSubplotsGTK as ConfigureSubplotsGTK4,
|
|
RubberbandGTK as RubberbandGTK4,
|
|
)
|
|
|
|
|
|
def _mpl_to_gtk_cursor(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 FigureCanvasGTK4(Gtk.DrawingArea, FigureCanvasBase):
|
|
required_interactive_framework = "gtk4"
|
|
supports_blit = False
|
|
_timer_cls = TimerGTK4
|
|
_context_is_scaled = False
|
|
|
|
def __init__(self, figure=None):
|
|
FigureCanvasBase.__init__(self, figure)
|
|
GObject.GObject.__init__(self)
|
|
self.set_hexpand(True)
|
|
self.set_vexpand(True)
|
|
|
|
self._idle_draw_id = 0
|
|
self._lastCursor = None
|
|
self._rubberband_rect = None
|
|
|
|
self.set_draw_func(self._draw_func)
|
|
self.connect('resize', self.resize_event)
|
|
self.connect('notify::scale-factor', self._update_device_pixel_ratio)
|
|
|
|
click = Gtk.GestureClick()
|
|
click.set_button(0) # All buttons.
|
|
click.connect('pressed', self.button_press_event)
|
|
click.connect('released', self.button_release_event)
|
|
self.add_controller(click)
|
|
|
|
key = Gtk.EventControllerKey()
|
|
key.connect('key-pressed', self.key_press_event)
|
|
key.connect('key-released', self.key_release_event)
|
|
self.add_controller(key)
|
|
|
|
motion = Gtk.EventControllerMotion()
|
|
motion.connect('motion', self.motion_notify_event)
|
|
motion.connect('enter', self.enter_notify_event)
|
|
motion.connect('leave', self.leave_notify_event)
|
|
self.add_controller(motion)
|
|
|
|
scroll = Gtk.EventControllerScroll.new(
|
|
Gtk.EventControllerScrollFlags.VERTICAL)
|
|
scroll.connect('scroll', self.scroll_event)
|
|
self.add_controller(scroll)
|
|
|
|
self.set_focusable(True)
|
|
|
|
css = Gtk.CssProvider()
|
|
css.load_from_data(b".matplotlib-canvas { background-color: white; }")
|
|
style_ctx = self.get_style_context()
|
|
style_ctx.add_provider(css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
|
style_ctx.add_class("matplotlib-canvas")
|
|
|
|
def pick(self, mouseevent):
|
|
# GtkWidget defines pick in GTK4, so we need to override here to work
|
|
# with the base implementation we want.
|
|
FigureCanvasBase.pick(self, mouseevent)
|
|
|
|
def destroy(self):
|
|
self.close_event()
|
|
|
|
def set_cursor(self, cursor):
|
|
# docstring inherited
|
|
self.set_cursor_from_name(_mpl_to_gtk_cursor(cursor))
|
|
|
|
def _mouse_event_coords(self, x, y):
|
|
"""
|
|
Calculate mouse coordinates in physical pixels.
|
|
|
|
GTK use logical pixels, but the figure is scaled to physical pixels for
|
|
rendering. Transform to physical pixels so that all of the down-stream
|
|
transforms work as expected.
|
|
|
|
Also, the origin is different and needs to be corrected.
|
|
"""
|
|
x = x * self.device_pixel_ratio
|
|
# flip y so y=0 is bottom of canvas
|
|
y = self.figure.bbox.height - y * self.device_pixel_ratio
|
|
return x, y
|
|
|
|
def scroll_event(self, controller, dx, dy):
|
|
FigureCanvasBase.scroll_event(self, 0, 0, dy)
|
|
return True
|
|
|
|
def button_press_event(self, controller, n_press, x, y):
|
|
x, y = self._mouse_event_coords(x, y)
|
|
FigureCanvasBase.button_press_event(self, x, y,
|
|
controller.get_current_button())
|
|
self.grab_focus()
|
|
|
|
def button_release_event(self, controller, n_press, x, y):
|
|
x, y = self._mouse_event_coords(x, y)
|
|
FigureCanvasBase.button_release_event(self, x, y,
|
|
controller.get_current_button())
|
|
|
|
def key_press_event(self, controller, keyval, keycode, state):
|
|
key = self._get_key(keyval, keycode, state)
|
|
FigureCanvasBase.key_press_event(self, key)
|
|
return True
|
|
|
|
def key_release_event(self, controller, keyval, keycode, state):
|
|
key = self._get_key(keyval, keycode, state)
|
|
FigureCanvasBase.key_release_event(self, key)
|
|
return True
|
|
|
|
def motion_notify_event(self, controller, x, y):
|
|
x, y = self._mouse_event_coords(x, y)
|
|
FigureCanvasBase.motion_notify_event(self, x, y)
|
|
|
|
def leave_notify_event(self, controller):
|
|
FigureCanvasBase.leave_notify_event(self)
|
|
|
|
def enter_notify_event(self, controller, x, y):
|
|
x, y = self._mouse_event_coords(x, y)
|
|
FigureCanvasBase.enter_notify_event(self, xy=(x, y))
|
|
|
|
def resize_event(self, area, width, height):
|
|
self._update_device_pixel_ratio()
|
|
dpi = self.figure.dpi
|
|
winch = width * self.device_pixel_ratio / dpi
|
|
hinch = height * self.device_pixel_ratio / dpi
|
|
self.figure.set_size_inches(winch, hinch, forward=False)
|
|
FigureCanvasBase.resize_event(self)
|
|
self.draw_idle()
|
|
|
|
def _get_key(self, keyval, keycode, state):
|
|
unikey = chr(Gdk.keyval_to_unicode(keyval))
|
|
key = cbook._unikey_or_keysym_to_mplkey(
|
|
unikey,
|
|
Gdk.keyval_name(keyval))
|
|
modifiers = [
|
|
(Gdk.ModifierType.CONTROL_MASK, 'ctrl'),
|
|
(Gdk.ModifierType.ALT_MASK, 'alt'),
|
|
(Gdk.ModifierType.SHIFT_MASK, 'shift'),
|
|
(Gdk.ModifierType.SUPER_MASK, 'super'),
|
|
]
|
|
for key_mask, prefix in modifiers:
|
|
if state & key_mask:
|
|
if not (prefix == 'shift' and unikey.isprintable()):
|
|
key = f'{prefix}+{key}'
|
|
return key
|
|
|
|
def _update_device_pixel_ratio(self, *args, **kwargs):
|
|
# We need to be careful in cases with mixed resolution displays if
|
|
# device_pixel_ratio changes.
|
|
if self._set_device_pixel_ratio(self.get_scale_factor()):
|
|
self.draw()
|
|
|
|
def _draw_rubberband(self, rect):
|
|
self._rubberband_rect = rect
|
|
# TODO: Only update the rubberband area.
|
|
self.queue_draw()
|
|
|
|
def _draw_func(self, drawing_area, ctx, width, height):
|
|
self.on_draw_event(self, ctx)
|
|
self._post_draw(self, ctx)
|
|
|
|
def _post_draw(self, widget, ctx):
|
|
if self._rubberband_rect is None:
|
|
return
|
|
|
|
lw = 1
|
|
dash = 3
|
|
if not self._context_is_scaled:
|
|
x0, y0, w, h = (dim / self.device_pixel_ratio
|
|
for dim in self._rubberband_rect)
|
|
else:
|
|
x0, y0, w, h = self._rubberband_rect
|
|
lw *= self.device_pixel_ratio
|
|
dash *= self.device_pixel_ratio
|
|
x1 = x0 + w
|
|
y1 = y0 + h
|
|
|
|
# Draw the lines from x0, y0 towards x1, y1 so that the
|
|
# dashes don't "jump" when moving the zoom box.
|
|
ctx.move_to(x0, y0)
|
|
ctx.line_to(x0, y1)
|
|
ctx.move_to(x0, y0)
|
|
ctx.line_to(x1, y0)
|
|
ctx.move_to(x0, y1)
|
|
ctx.line_to(x1, y1)
|
|
ctx.move_to(x1, y0)
|
|
ctx.line_to(x1, y1)
|
|
|
|
ctx.set_antialias(1)
|
|
ctx.set_line_width(lw)
|
|
ctx.set_dash((dash, dash), 0)
|
|
ctx.set_source_rgb(0, 0, 0)
|
|
ctx.stroke_preserve()
|
|
|
|
ctx.set_dash((dash, dash), dash)
|
|
ctx.set_source_rgb(1, 1, 1)
|
|
ctx.stroke()
|
|
|
|
def on_draw_event(self, widget, ctx):
|
|
# to be overwritten by GTK4Agg or GTK4Cairo
|
|
pass
|
|
|
|
def draw(self):
|
|
# docstring inherited
|
|
if self.is_drawable():
|
|
self.queue_draw()
|
|
|
|
def draw_idle(self):
|
|
# docstring inherited
|
|
if self._idle_draw_id != 0:
|
|
return
|
|
def idle_draw(*args):
|
|
try:
|
|
self.draw()
|
|
finally:
|
|
self._idle_draw_id = 0
|
|
return False
|
|
self._idle_draw_id = GLib.idle_add(idle_draw)
|
|
|
|
def flush_events(self):
|
|
# docstring inherited
|
|
context = GLib.MainContext.default()
|
|
while context.pending():
|
|
context.iteration(True)
|
|
|
|
|
|
class FigureManagerGTK4(FigureManagerBase):
|
|
"""
|
|
Attributes
|
|
----------
|
|
canvas : `FigureCanvas`
|
|
The FigureCanvas instance
|
|
num : int or str
|
|
The Figure number
|
|
toolbar : 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):
|
|
app = _create_application()
|
|
self.window = Gtk.Window()
|
|
app.add_window(self.window)
|
|
super().__init__(canvas, num)
|
|
|
|
self.vbox = Gtk.Box()
|
|
self.vbox.set_property("orientation", Gtk.Orientation.VERTICAL)
|
|
self.window.set_child(self.vbox)
|
|
|
|
self.vbox.prepend(self.canvas)
|
|
# calculate size for window
|
|
w, h = self.canvas.get_width_height()
|
|
|
|
self.toolbar = self._get_toolbar()
|
|
|
|
if self.toolmanager:
|
|
backend_tools.add_tools_to_manager(self.toolmanager)
|
|
if self.toolbar:
|
|
backend_tools.add_tools_to_container(self.toolbar)
|
|
|
|
if self.toolbar is not None:
|
|
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("close-request", 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()
|
|
|
|
def show(self):
|
|
# show the figure window
|
|
self.window.show()
|
|
self.canvas.draw()
|
|
if mpl.rcParams['figure.raise_window']:
|
|
if self.window.get_surface():
|
|
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 GdkSurface (self.window.get_surface()) yet, and
|
|
# present() would crash.
|
|
_api.warn_external("Cannot raise window yet to be setup")
|
|
|
|
def full_screen_toggle(self):
|
|
if not self.window.is_fullscreen():
|
|
self.window.fullscreen()
|
|
else:
|
|
self.window.unfullscreen()
|
|
|
|
def _get_toolbar(self):
|
|
# must be inited after the window, drawingArea and figure
|
|
# attrs are set
|
|
if mpl.rcParams['toolbar'] == 'toolbar2':
|
|
toolbar = NavigationToolbar2GTK4(self.canvas, self.window)
|
|
elif mpl.rcParams['toolbar'] == 'toolmanager':
|
|
toolbar = ToolbarGTK4(self.toolmanager)
|
|
else:
|
|
toolbar = None
|
|
return toolbar
|
|
|
|
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):
|
|
"""Set the canvas size in pixels."""
|
|
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()
|
|
self.window.set_default_size(width, height)
|
|
|
|
|
|
class NavigationToolbar2GTK4(_NavigationToolbar2GTK, Gtk.Box):
|
|
def __init__(self, canvas, window):
|
|
self.win = window
|
|
Gtk.Box.__init__(self)
|
|
|
|
self.add_css_class('toolbar')
|
|
|
|
self._gtk_ids = {}
|
|
for text, tooltip_text, image_file, callback in self.toolitems:
|
|
if text is None:
|
|
self.append(Gtk.Separator())
|
|
continue
|
|
image = Gtk.Image.new_from_gicon(
|
|
Gio.Icon.new_for_string(
|
|
str(cbook._get_data_path('images',
|
|
f'{image_file}-symbolic.svg'))))
|
|
self._gtk_ids[text] = button = (
|
|
Gtk.ToggleButton() if callback in ['zoom', 'pan'] else
|
|
Gtk.Button())
|
|
button.set_child(image)
|
|
button.add_css_class('flat')
|
|
button.add_css_class('image-button')
|
|
# Save the handler id, so that we can block it as needed.
|
|
button._signal_handler = button.connect(
|
|
'clicked', getattr(self, callback))
|
|
button.set_tooltip_text(tooltip_text)
|
|
self.append(button)
|
|
|
|
# This filler item ensures the toolbar is always at least two text
|
|
# lines high. Otherwise the canvas gets redrawn as the mouse hovers
|
|
# over images because those use two-line messages which resize the
|
|
# toolbar.
|
|
label = Gtk.Label()
|
|
label.set_markup(
|
|
'<small>\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}</small>')
|
|
label.set_hexpand(True) # Push real message to the right.
|
|
self.append(label)
|
|
|
|
self.message = Gtk.Label()
|
|
self.append(self.message)
|
|
|
|
NavigationToolbar2.__init__(self, canvas)
|
|
|
|
def save_figure(self, *args):
|
|
dialog = Gtk.FileChooserNative(
|
|
title='Save the figure',
|
|
transient_for=self.canvas.get_root(),
|
|
action=Gtk.FileChooserAction.SAVE,
|
|
modal=True)
|
|
self._save_dialog = dialog # Must keep a reference.
|
|
|
|
ff = Gtk.FileFilter()
|
|
ff.set_name('All files')
|
|
ff.add_pattern('*')
|
|
dialog.add_filter(ff)
|
|
dialog.set_filter(ff)
|
|
|
|
formats = []
|
|
default_format = None
|
|
for i, (name, fmts) in enumerate(
|
|
self.canvas.get_supported_filetypes_grouped().items()):
|
|
ff = Gtk.FileFilter()
|
|
ff.set_name(name)
|
|
for fmt in fmts:
|
|
ff.add_pattern(f'*.{fmt}')
|
|
dialog.add_filter(ff)
|
|
formats.append(name)
|
|
if self.canvas.get_default_filetype() in fmts:
|
|
default_format = i
|
|
# Setting the choice doesn't always work, so make sure the default
|
|
# format is first.
|
|
formats = [formats[default_format], *formats[:default_format],
|
|
*formats[default_format+1:]]
|
|
dialog.add_choice('format', 'File format', formats, formats)
|
|
dialog.set_choice('format', formats[default_format])
|
|
|
|
dialog.set_current_folder(Gio.File.new_for_path(
|
|
os.path.expanduser(mpl.rcParams['savefig.directory'])))
|
|
dialog.set_current_name(self.canvas.get_default_filename())
|
|
|
|
@functools.partial(dialog.connect, 'response')
|
|
def on_response(dialog, response):
|
|
file = dialog.get_file()
|
|
fmt = dialog.get_choice('format')
|
|
fmt = self.canvas.get_supported_filetypes_grouped()[fmt][0]
|
|
dialog.destroy()
|
|
self._save_dialog = None
|
|
if response != Gtk.ResponseType.ACCEPT:
|
|
return
|
|
# Save dir for next time, unless empty str (which means use cwd).
|
|
if mpl.rcParams['savefig.directory']:
|
|
parent = file.get_parent()
|
|
mpl.rcParams['savefig.directory'] = parent.get_path()
|
|
try:
|
|
self.canvas.figure.savefig(file.get_path(), format=fmt)
|
|
except Exception as e:
|
|
msg = Gtk.MessageDialog(
|
|
transient_for=self.canvas.get_root(),
|
|
message_type=Gtk.MessageType.ERROR,
|
|
buttons=Gtk.ButtonsType.OK, modal=True,
|
|
text=str(e))
|
|
msg.show()
|
|
|
|
dialog.show()
|
|
|
|
|
|
class ToolbarGTK4(ToolContainerBase, Gtk.Box):
|
|
_icon_extension = '-symbolic.svg'
|
|
|
|
def __init__(self, toolmanager):
|
|
ToolContainerBase.__init__(self, toolmanager)
|
|
Gtk.Box.__init__(self)
|
|
self.set_property('orientation', Gtk.Orientation.HORIZONTAL)
|
|
|
|
# Tool items are created later, but must appear before the message.
|
|
self._tool_box = Gtk.Box()
|
|
self.append(self._tool_box)
|
|
self._groups = {}
|
|
self._toolitems = {}
|
|
|
|
# This filler item ensures the toolbar is always at least two text
|
|
# lines high. Otherwise the canvas gets redrawn as the mouse hovers
|
|
# over images because those use two-line messages which resize the
|
|
# toolbar.
|
|
label = Gtk.Label()
|
|
label.set_markup(
|
|
'<small>\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}</small>')
|
|
label.set_hexpand(True) # Push real message to the right.
|
|
self.append(label)
|
|
|
|
self._message = Gtk.Label()
|
|
self.append(self._message)
|
|
|
|
def add_toolitem(self, name, group, position, image_file, description,
|
|
toggle):
|
|
if toggle:
|
|
button = Gtk.ToggleButton()
|
|
else:
|
|
button = Gtk.Button()
|
|
button.set_label(name)
|
|
button.add_css_class('flat')
|
|
|
|
if image_file is not None:
|
|
image = Gtk.Image.new_from_gicon(
|
|
Gio.Icon.new_for_string(image_file))
|
|
button.set_child(image)
|
|
button.add_css_class('image-button')
|
|
|
|
if position is None:
|
|
position = -1
|
|
|
|
self._add_button(button, group, position)
|
|
signal = button.connect('clicked', self._call_tool, name)
|
|
button.set_tooltip_text(description)
|
|
self._toolitems.setdefault(name, [])
|
|
self._toolitems[name].append((button, signal))
|
|
|
|
def _find_child_at_position(self, group, position):
|
|
children = [None]
|
|
child = self._groups[group].get_first_child()
|
|
while child is not None:
|
|
children.append(child)
|
|
child = child.get_next_sibling()
|
|
return children[position]
|
|
|
|
def _add_button(self, button, group, position):
|
|
if group not in self._groups:
|
|
if self._groups:
|
|
self._add_separator()
|
|
group_box = Gtk.Box()
|
|
self._tool_box.append(group_box)
|
|
self._groups[group] = group_box
|
|
self._groups[group].insert_child_after(
|
|
button, self._find_child_at_position(group, position))
|
|
|
|
def _call_tool(self, btn, name):
|
|
self.trigger_tool(name)
|
|
|
|
def toggle_toolitem(self, name, toggled):
|
|
if name not in self._toolitems:
|
|
return
|
|
for toolitem, signal in self._toolitems[name]:
|
|
toolitem.handler_block(signal)
|
|
toolitem.set_active(toggled)
|
|
toolitem.handler_unblock(signal)
|
|
|
|
def remove_toolitem(self, name):
|
|
if name not in self._toolitems:
|
|
self.toolmanager.message_event(f'{name} not in toolbar', self)
|
|
return
|
|
|
|
for group in self._groups:
|
|
for toolitem, _signal in self._toolitems[name]:
|
|
if toolitem in self._groups[group]:
|
|
self._groups[group].remove(toolitem)
|
|
del self._toolitems[name]
|
|
|
|
def _add_separator(self):
|
|
sep = Gtk.Separator()
|
|
sep.set_property("orientation", Gtk.Orientation.VERTICAL)
|
|
self._tool_box.append(sep)
|
|
|
|
def set_message(self, s):
|
|
self._message.set_label(s)
|
|
|
|
|
|
class SaveFigureGTK4(backend_tools.SaveFigureBase):
|
|
def trigger(self, *args, **kwargs):
|
|
|
|
class PseudoToolbar:
|
|
canvas = self.figure.canvas
|
|
|
|
return NavigationToolbar2GTK4.save_figure(PseudoToolbar())
|
|
|
|
|
|
class HelpGTK4(backend_tools.ToolHelpBase):
|
|
def _normalize_shortcut(self, key):
|
|
"""
|
|
Convert Matplotlib key presses to GTK+ accelerator identifiers.
|
|
|
|
Related to `FigureCanvasGTK4._get_key`.
|
|
"""
|
|
special = {
|
|
'backspace': 'BackSpace',
|
|
'pagedown': 'Page_Down',
|
|
'pageup': 'Page_Up',
|
|
'scroll_lock': 'Scroll_Lock',
|
|
}
|
|
|
|
parts = key.split('+')
|
|
mods = ['<' + mod + '>' for mod in parts[:-1]]
|
|
key = parts[-1]
|
|
|
|
if key in special:
|
|
key = special[key]
|
|
elif len(key) > 1:
|
|
key = key.capitalize()
|
|
elif key.isupper():
|
|
mods += ['<shift>']
|
|
|
|
return ''.join(mods) + key
|
|
|
|
def _is_valid_shortcut(self, key):
|
|
"""
|
|
Check for a valid shortcut to be displayed.
|
|
|
|
- GTK will never send 'cmd+' (see `FigureCanvasGTK4._get_key`).
|
|
- The shortcut window only shows keyboard shortcuts, not mouse buttons.
|
|
"""
|
|
return 'cmd+' not in key and not key.startswith('MouseButton.')
|
|
|
|
def trigger(self, *args):
|
|
section = Gtk.ShortcutsSection()
|
|
|
|
for name, tool in sorted(self.toolmanager.tools.items()):
|
|
if not tool.description:
|
|
continue
|
|
|
|
# Putting everything in a separate group allows GTK to
|
|
# automatically split them into separate columns/pages, which is
|
|
# useful because we have lots of shortcuts, some with many keys
|
|
# that are very wide.
|
|
group = Gtk.ShortcutsGroup()
|
|
section.append(group)
|
|
# A hack to remove the title since we have no group naming.
|
|
child = group.get_first_child()
|
|
while child is not None:
|
|
child.set_visible(False)
|
|
child = child.get_next_sibling()
|
|
|
|
shortcut = Gtk.ShortcutsShortcut(
|
|
accelerator=' '.join(
|
|
self._normalize_shortcut(key)
|
|
for key in self.toolmanager.get_tool_keymap(name)
|
|
if self._is_valid_shortcut(key)),
|
|
title=tool.name,
|
|
subtitle=tool.description)
|
|
group.append(shortcut)
|
|
|
|
window = Gtk.ShortcutsWindow(
|
|
title='Help',
|
|
modal=True,
|
|
transient_for=self._figure.canvas.get_root())
|
|
window.set_child(section)
|
|
|
|
window.show()
|
|
|
|
|
|
class ToolCopyToClipboardGTK4(backend_tools.ToolCopyToClipboardBase):
|
|
def trigger(self, *args, **kwargs):
|
|
with io.BytesIO() as f:
|
|
self.canvas.print_rgba(f)
|
|
w, h = self.canvas.get_width_height()
|
|
pb = GdkPixbuf.Pixbuf.new_from_data(f.getbuffer(),
|
|
GdkPixbuf.Colorspace.RGB, True,
|
|
8, w, h, w*4)
|
|
clipboard = self.canvas.get_clipboard()
|
|
clipboard.set(pb)
|
|
|
|
|
|
backend_tools.ToolSaveFigure = SaveFigureGTK4
|
|
backend_tools.ToolConfigureSubplots = ConfigureSubplotsGTK4
|
|
backend_tools.ToolRubberband = RubberbandGTK4
|
|
backend_tools.ToolHelp = HelpGTK4
|
|
backend_tools.ToolCopyToClipboard = ToolCopyToClipboardGTK4
|
|
|
|
Toolbar = ToolbarGTK4
|
|
|
|
|
|
@_Backend.export
|
|
class _BackendGTK4(_BackendGTK):
|
|
FigureCanvas = FigureCanvasGTK4
|
|
FigureManager = FigureManagerGTK4
|