1101 lines
39 KiB
Python
1101 lines
39 KiB
Python
import functools
|
|
import os
|
|
import re
|
|
import signal
|
|
import sys
|
|
import traceback
|
|
|
|
import matplotlib
|
|
|
|
from matplotlib import backend_tools, cbook
|
|
from matplotlib._pylab_helpers import Gcf
|
|
from matplotlib.backend_bases import (
|
|
_Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2,
|
|
TimerBase, cursors, ToolContainerBase, StatusbarBase, MouseButton)
|
|
import matplotlib.backends.qt_editor.figureoptions as figureoptions
|
|
from matplotlib.backends.qt_editor.formsubplottool import UiSubplotTool
|
|
from matplotlib.backend_managers import ToolManager
|
|
|
|
from .qt_compat import (
|
|
QtCore, QtGui, QtWidgets, _getSaveFileName, is_pyqt5, __version__, QT_API)
|
|
|
|
backend_version = __version__
|
|
|
|
# SPECIAL_KEYS are keys that do *not* return their unicode name
|
|
# instead they have manually specified names
|
|
SPECIAL_KEYS = {QtCore.Qt.Key_Control: 'control',
|
|
QtCore.Qt.Key_Shift: 'shift',
|
|
QtCore.Qt.Key_Alt: 'alt',
|
|
QtCore.Qt.Key_Meta: 'super',
|
|
QtCore.Qt.Key_Return: 'enter',
|
|
QtCore.Qt.Key_Left: 'left',
|
|
QtCore.Qt.Key_Up: 'up',
|
|
QtCore.Qt.Key_Right: 'right',
|
|
QtCore.Qt.Key_Down: 'down',
|
|
QtCore.Qt.Key_Escape: 'escape',
|
|
QtCore.Qt.Key_F1: 'f1',
|
|
QtCore.Qt.Key_F2: 'f2',
|
|
QtCore.Qt.Key_F3: 'f3',
|
|
QtCore.Qt.Key_F4: 'f4',
|
|
QtCore.Qt.Key_F5: 'f5',
|
|
QtCore.Qt.Key_F6: 'f6',
|
|
QtCore.Qt.Key_F7: 'f7',
|
|
QtCore.Qt.Key_F8: 'f8',
|
|
QtCore.Qt.Key_F9: 'f9',
|
|
QtCore.Qt.Key_F10: 'f10',
|
|
QtCore.Qt.Key_F11: 'f11',
|
|
QtCore.Qt.Key_F12: 'f12',
|
|
QtCore.Qt.Key_Home: 'home',
|
|
QtCore.Qt.Key_End: 'end',
|
|
QtCore.Qt.Key_PageUp: 'pageup',
|
|
QtCore.Qt.Key_PageDown: 'pagedown',
|
|
QtCore.Qt.Key_Tab: 'tab',
|
|
QtCore.Qt.Key_Backspace: 'backspace',
|
|
QtCore.Qt.Key_Enter: 'enter',
|
|
QtCore.Qt.Key_Insert: 'insert',
|
|
QtCore.Qt.Key_Delete: 'delete',
|
|
QtCore.Qt.Key_Pause: 'pause',
|
|
QtCore.Qt.Key_SysReq: 'sysreq',
|
|
QtCore.Qt.Key_Clear: 'clear', }
|
|
|
|
# define which modifier keys are collected on keyboard events.
|
|
# elements are (mpl names, Modifier Flag, Qt Key) tuples
|
|
SUPER = 0
|
|
ALT = 1
|
|
CTRL = 2
|
|
SHIFT = 3
|
|
MODIFIER_KEYS = [('super', QtCore.Qt.MetaModifier, QtCore.Qt.Key_Meta),
|
|
('alt', QtCore.Qt.AltModifier, QtCore.Qt.Key_Alt),
|
|
('ctrl', QtCore.Qt.ControlModifier, QtCore.Qt.Key_Control),
|
|
('shift', QtCore.Qt.ShiftModifier, QtCore.Qt.Key_Shift),
|
|
]
|
|
|
|
if sys.platform == 'darwin':
|
|
# in OSX, the control and super (aka cmd/apple) keys are switched, so
|
|
# switch them back.
|
|
SPECIAL_KEYS.update({QtCore.Qt.Key_Control: 'cmd', # cmd/apple key
|
|
QtCore.Qt.Key_Meta: 'control',
|
|
})
|
|
MODIFIER_KEYS[0] = ('cmd', QtCore.Qt.ControlModifier,
|
|
QtCore.Qt.Key_Control)
|
|
MODIFIER_KEYS[2] = ('ctrl', QtCore.Qt.MetaModifier,
|
|
QtCore.Qt.Key_Meta)
|
|
|
|
|
|
cursord = {
|
|
cursors.MOVE: QtCore.Qt.SizeAllCursor,
|
|
cursors.HAND: QtCore.Qt.PointingHandCursor,
|
|
cursors.POINTER: QtCore.Qt.ArrowCursor,
|
|
cursors.SELECT_REGION: QtCore.Qt.CrossCursor,
|
|
cursors.WAIT: QtCore.Qt.WaitCursor,
|
|
}
|
|
|
|
|
|
# make place holder
|
|
qApp = None
|
|
|
|
|
|
def _create_qApp():
|
|
"""
|
|
Only one qApp can exist at a time, so check before creating one.
|
|
"""
|
|
global qApp
|
|
|
|
if qApp is None:
|
|
app = QtWidgets.QApplication.instance()
|
|
if app is None:
|
|
# check for DISPLAY env variable on X11 build of Qt
|
|
if is_pyqt5():
|
|
try:
|
|
from PyQt5 import QtX11Extras
|
|
is_x11_build = True
|
|
except ImportError:
|
|
is_x11_build = False
|
|
else:
|
|
is_x11_build = hasattr(QtGui, "QX11Info")
|
|
if is_x11_build:
|
|
display = os.environ.get('DISPLAY')
|
|
if display is None or not re.search(r':\d', display):
|
|
raise RuntimeError('Invalid DISPLAY variable')
|
|
|
|
try:
|
|
QtWidgets.QApplication.setAttribute(
|
|
QtCore.Qt.AA_EnableHighDpiScaling)
|
|
except AttributeError: # Attribute only exists for Qt>=5.6.
|
|
pass
|
|
qApp = QtWidgets.QApplication([b"matplotlib"])
|
|
qApp.lastWindowClosed.connect(qApp.quit)
|
|
else:
|
|
qApp = app
|
|
|
|
if is_pyqt5():
|
|
try:
|
|
qApp.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps)
|
|
except AttributeError:
|
|
pass
|
|
|
|
|
|
def _allow_super_init(__init__):
|
|
"""
|
|
Decorator for ``__init__`` to allow ``super().__init__`` on PyQt4/PySide2.
|
|
"""
|
|
|
|
if QT_API == "PyQt5":
|
|
|
|
return __init__
|
|
|
|
else:
|
|
# To work around lack of cooperative inheritance in PyQt4, PySide,
|
|
# and PySide2, when calling FigureCanvasQT.__init__, we temporarily
|
|
# patch QWidget.__init__ by a cooperative version, that first calls
|
|
# QWidget.__init__ with no additional arguments, and then finds the
|
|
# next class in the MRO with an __init__ that does support cooperative
|
|
# inheritance (i.e., not defined by the PyQt4, PySide, PySide2, sip
|
|
# or Shiboken packages), and manually call its `__init__`, once again
|
|
# passing the additional arguments.
|
|
|
|
qwidget_init = QtWidgets.QWidget.__init__
|
|
|
|
def cooperative_qwidget_init(self, *args, **kwargs):
|
|
qwidget_init(self)
|
|
mro = type(self).__mro__
|
|
next_coop_init = next(
|
|
cls for cls in mro[mro.index(QtWidgets.QWidget) + 1:]
|
|
if cls.__module__.split(".")[0] not in [
|
|
"PyQt4", "sip", "PySide", "PySide2", "Shiboken"])
|
|
next_coop_init.__init__(self, *args, **kwargs)
|
|
|
|
@functools.wraps(__init__)
|
|
def wrapper(self, *args, **kwargs):
|
|
with cbook._setattr_cm(QtWidgets.QWidget,
|
|
__init__=cooperative_qwidget_init):
|
|
__init__(self, *args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
|
|
class TimerQT(TimerBase):
|
|
'''
|
|
Subclass of :class:`backend_bases.TimerBase` that uses Qt timer events.
|
|
|
|
Attributes
|
|
----------
|
|
interval : int
|
|
The time between timer events in milliseconds. Default is 1000 ms.
|
|
single_shot : bool
|
|
Boolean flag indicating whether this timer should
|
|
operate as single shot (run once and then stop). Defaults to False.
|
|
callbacks : list
|
|
Stores list of (func, args) tuples that will be called upon timer
|
|
events. This list can be manipulated directly, or the functions
|
|
`add_callback` and `remove_callback` can be used.
|
|
|
|
'''
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
TimerBase.__init__(self, *args, **kwargs)
|
|
|
|
# Create a new timer and connect the timeout() signal to the
|
|
# _on_timer method.
|
|
self._timer = QtCore.QTimer()
|
|
self._timer.timeout.connect(self._on_timer)
|
|
self._timer_set_interval()
|
|
|
|
def _timer_set_single_shot(self):
|
|
self._timer.setSingleShot(self._single)
|
|
|
|
def _timer_set_interval(self):
|
|
self._timer.setInterval(self._interval)
|
|
|
|
def _timer_start(self):
|
|
self._timer.start()
|
|
|
|
def _timer_stop(self):
|
|
self._timer.stop()
|
|
|
|
|
|
class FigureCanvasQT(QtWidgets.QWidget, FigureCanvasBase):
|
|
|
|
# map Qt button codes to MouseEvent's ones:
|
|
buttond = {QtCore.Qt.LeftButton: MouseButton.LEFT,
|
|
QtCore.Qt.MidButton: MouseButton.MIDDLE,
|
|
QtCore.Qt.RightButton: MouseButton.RIGHT,
|
|
QtCore.Qt.XButton1: MouseButton.BACK,
|
|
QtCore.Qt.XButton2: MouseButton.FORWARD,
|
|
}
|
|
|
|
@_allow_super_init
|
|
def __init__(self, figure):
|
|
_create_qApp()
|
|
super().__init__(figure=figure)
|
|
|
|
self.figure = figure
|
|
# We don't want to scale up the figure DPI more than once.
|
|
# Note, we don't handle a signal for changing DPI yet.
|
|
figure._original_dpi = figure.dpi
|
|
self._update_figure_dpi()
|
|
# In cases with mixed resolution displays, we need to be careful if the
|
|
# dpi_ratio changes - in this case we need to resize the canvas
|
|
# accordingly. We could watch for screenChanged events from Qt, but
|
|
# the issue is that we can't guarantee this will be emitted *before*
|
|
# the first paintEvent for the canvas, so instead we keep track of the
|
|
# dpi_ratio value here and in paintEvent we resize the canvas if
|
|
# needed.
|
|
self._dpi_ratio_prev = None
|
|
|
|
self._draw_pending = False
|
|
self._is_drawing = False
|
|
self._draw_rect_callback = lambda painter: None
|
|
|
|
self.setAttribute(QtCore.Qt.WA_OpaquePaintEvent)
|
|
self.setMouseTracking(True)
|
|
self.resize(*self.get_width_height())
|
|
# Key auto-repeat enabled by default
|
|
self._keyautorepeat = True
|
|
|
|
palette = QtGui.QPalette(QtCore.Qt.white)
|
|
self.setPalette(palette)
|
|
|
|
def _update_figure_dpi(self):
|
|
dpi = self._dpi_ratio * self.figure._original_dpi
|
|
self.figure._set_dpi(dpi, forward=False)
|
|
|
|
@property
|
|
def _dpi_ratio(self):
|
|
# Not available on Qt4 or some older Qt5.
|
|
try:
|
|
# self.devicePixelRatio() returns 0 in rare cases
|
|
return self.devicePixelRatio() or 1
|
|
except AttributeError:
|
|
return 1
|
|
|
|
def _update_dpi(self):
|
|
# As described in __init__ above, we need to be careful in cases with
|
|
# mixed resolution displays if dpi_ratio is changing between painting
|
|
# events.
|
|
# Return whether we triggered a resizeEvent (and thus a paintEvent)
|
|
# from within this function.
|
|
if self._dpi_ratio != self._dpi_ratio_prev:
|
|
# We need to update the figure DPI.
|
|
self._update_figure_dpi()
|
|
self._dpi_ratio_prev = self._dpi_ratio
|
|
# The easiest way to resize the canvas is to emit a resizeEvent
|
|
# since we implement all the logic for resizing the canvas for
|
|
# that event.
|
|
event = QtGui.QResizeEvent(self.size(), self.size())
|
|
self.resizeEvent(event)
|
|
# resizeEvent triggers a paintEvent itself, so we exit this one
|
|
# (after making sure that the event is immediately handled).
|
|
return True
|
|
return False
|
|
|
|
def get_width_height(self):
|
|
w, h = FigureCanvasBase.get_width_height(self)
|
|
return int(w / self._dpi_ratio), int(h / self._dpi_ratio)
|
|
|
|
def enterEvent(self, event):
|
|
try:
|
|
x, y = self.mouseEventCoords(event.pos())
|
|
except AttributeError:
|
|
# the event from PyQt4 does not include the position
|
|
x = y = None
|
|
FigureCanvasBase.enter_notify_event(self, guiEvent=event, xy=(x, y))
|
|
|
|
def leaveEvent(self, event):
|
|
QtWidgets.QApplication.restoreOverrideCursor()
|
|
FigureCanvasBase.leave_notify_event(self, guiEvent=event)
|
|
|
|
def mouseEventCoords(self, pos):
|
|
"""Calculate mouse coordinates in physical pixels
|
|
|
|
Qt5 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.
|
|
|
|
"""
|
|
dpi_ratio = self._dpi_ratio
|
|
x = pos.x()
|
|
# flip y so y=0 is bottom of canvas
|
|
y = self.figure.bbox.height / dpi_ratio - pos.y()
|
|
return x * dpi_ratio, y * dpi_ratio
|
|
|
|
def mousePressEvent(self, event):
|
|
x, y = self.mouseEventCoords(event.pos())
|
|
button = self.buttond.get(event.button())
|
|
if button is not None:
|
|
FigureCanvasBase.button_press_event(self, x, y, button,
|
|
guiEvent=event)
|
|
|
|
def mouseDoubleClickEvent(self, event):
|
|
x, y = self.mouseEventCoords(event.pos())
|
|
button = self.buttond.get(event.button())
|
|
if button is not None:
|
|
FigureCanvasBase.button_press_event(self, x, y,
|
|
button, dblclick=True,
|
|
guiEvent=event)
|
|
|
|
def mouseMoveEvent(self, event):
|
|
x, y = self.mouseEventCoords(event)
|
|
FigureCanvasBase.motion_notify_event(self, x, y, guiEvent=event)
|
|
|
|
def mouseReleaseEvent(self, event):
|
|
x, y = self.mouseEventCoords(event)
|
|
button = self.buttond.get(event.button())
|
|
if button is not None:
|
|
FigureCanvasBase.button_release_event(self, x, y, button,
|
|
guiEvent=event)
|
|
|
|
if is_pyqt5():
|
|
def wheelEvent(self, event):
|
|
x, y = self.mouseEventCoords(event)
|
|
# from QWheelEvent::delta doc
|
|
if event.pixelDelta().x() == 0 and event.pixelDelta().y() == 0:
|
|
steps = event.angleDelta().y() / 120
|
|
else:
|
|
steps = event.pixelDelta().y()
|
|
if steps:
|
|
FigureCanvasBase.scroll_event(
|
|
self, x, y, steps, guiEvent=event)
|
|
else:
|
|
def wheelEvent(self, event):
|
|
x = event.x()
|
|
# flipy so y=0 is bottom of canvas
|
|
y = self.figure.bbox.height - event.y()
|
|
# from QWheelEvent::delta doc
|
|
steps = event.delta() / 120
|
|
if event.orientation() == QtCore.Qt.Vertical:
|
|
FigureCanvasBase.scroll_event(
|
|
self, x, y, steps, guiEvent=event)
|
|
|
|
def keyPressEvent(self, event):
|
|
key = self._get_key(event)
|
|
if key is not None:
|
|
FigureCanvasBase.key_press_event(self, key, guiEvent=event)
|
|
|
|
def keyReleaseEvent(self, event):
|
|
key = self._get_key(event)
|
|
if key is not None:
|
|
FigureCanvasBase.key_release_event(self, key, guiEvent=event)
|
|
|
|
@cbook.deprecated("3.0", alternative="event.guiEvent.isAutoRepeat")
|
|
@property
|
|
def keyAutoRepeat(self):
|
|
"""
|
|
If True, enable auto-repeat for key events.
|
|
"""
|
|
return self._keyautorepeat
|
|
|
|
@keyAutoRepeat.setter
|
|
def keyAutoRepeat(self, val):
|
|
self._keyautorepeat = bool(val)
|
|
|
|
def resizeEvent(self, event):
|
|
# _dpi_ratio_prev will be set the first time the canvas is painted, and
|
|
# the rendered buffer is useless before anyways.
|
|
if self._dpi_ratio_prev is None:
|
|
return
|
|
w = event.size().width() * self._dpi_ratio
|
|
h = event.size().height() * self._dpi_ratio
|
|
dpival = self.figure.dpi
|
|
winch = w / dpival
|
|
hinch = h / dpival
|
|
self.figure.set_size_inches(winch, hinch, forward=False)
|
|
# pass back into Qt to let it finish
|
|
QtWidgets.QWidget.resizeEvent(self, event)
|
|
# emit our resize events
|
|
FigureCanvasBase.resize_event(self)
|
|
|
|
def sizeHint(self):
|
|
w, h = self.get_width_height()
|
|
return QtCore.QSize(w, h)
|
|
|
|
def minumumSizeHint(self):
|
|
return QtCore.QSize(10, 10)
|
|
|
|
def _get_key(self, event):
|
|
if not self._keyautorepeat and event.isAutoRepeat():
|
|
return None
|
|
|
|
event_key = event.key()
|
|
event_mods = int(event.modifiers()) # actually a bitmask
|
|
|
|
# get names of the pressed modifier keys
|
|
# bit twiddling to pick out modifier keys from event_mods bitmask,
|
|
# if event_key is a MODIFIER, it should not be duplicated in mods
|
|
mods = [name for name, mod_key, qt_key in MODIFIER_KEYS
|
|
if event_key != qt_key and (event_mods & mod_key) == mod_key]
|
|
try:
|
|
# for certain keys (enter, left, backspace, etc) use a word for the
|
|
# key, rather than unicode
|
|
key = SPECIAL_KEYS[event_key]
|
|
except KeyError:
|
|
# unicode defines code points up to 0x0010ffff
|
|
# QT will use Key_Codes larger than that for keyboard keys that are
|
|
# are not unicode characters (like multimedia keys)
|
|
# skip these
|
|
# if you really want them, you should add them to SPECIAL_KEYS
|
|
MAX_UNICODE = 0x10ffff
|
|
if event_key > MAX_UNICODE:
|
|
return None
|
|
|
|
key = chr(event_key)
|
|
# qt delivers capitalized letters. fix capitalization
|
|
# note that capslock is ignored
|
|
if 'shift' in mods:
|
|
mods.remove('shift')
|
|
else:
|
|
key = key.lower()
|
|
|
|
mods.reverse()
|
|
return '+'.join(mods + [key])
|
|
|
|
def new_timer(self, *args, **kwargs):
|
|
# docstring inherited
|
|
return TimerQT(*args, **kwargs)
|
|
|
|
def flush_events(self):
|
|
# docstring inherited
|
|
qApp.processEvents()
|
|
|
|
def start_event_loop(self, timeout=0):
|
|
# docstring inherited
|
|
if hasattr(self, "_event_loop") and self._event_loop.isRunning():
|
|
raise RuntimeError("Event loop already running")
|
|
self._event_loop = event_loop = QtCore.QEventLoop()
|
|
if timeout:
|
|
timer = QtCore.QTimer.singleShot(timeout * 1000, event_loop.quit)
|
|
event_loop.exec_()
|
|
|
|
def stop_event_loop(self, event=None):
|
|
# docstring inherited
|
|
if hasattr(self, "_event_loop"):
|
|
self._event_loop.quit()
|
|
|
|
def draw(self):
|
|
"""Render the figure, and queue a request for a Qt draw.
|
|
"""
|
|
# The renderer draw is done here; delaying causes problems with code
|
|
# that uses the result of the draw() to update plot elements.
|
|
if self._is_drawing:
|
|
return
|
|
with cbook._setattr_cm(self, _is_drawing=True):
|
|
super().draw()
|
|
self.update()
|
|
|
|
def draw_idle(self):
|
|
"""Queue redraw of the Agg buffer and request Qt paintEvent.
|
|
"""
|
|
# The Agg draw needs to be handled by the same thread matplotlib
|
|
# modifies the scene graph from. Post Agg draw request to the
|
|
# current event loop in order to ensure thread affinity and to
|
|
# accumulate multiple draw requests from event handling.
|
|
# TODO: queued signal connection might be safer than singleShot
|
|
if not (getattr(self, '_draw_pending', False) or
|
|
getattr(self, '_is_drawing', False)):
|
|
self._draw_pending = True
|
|
QtCore.QTimer.singleShot(0, self._draw_idle)
|
|
|
|
def _draw_idle(self):
|
|
if self.height() < 0 or self.width() < 0:
|
|
self._draw_pending = False
|
|
if not self._draw_pending:
|
|
return
|
|
try:
|
|
self.draw()
|
|
except Exception:
|
|
# Uncaught exceptions are fatal for PyQt5, so catch them instead.
|
|
traceback.print_exc()
|
|
finally:
|
|
self._draw_pending = False
|
|
|
|
def drawRectangle(self, rect):
|
|
# Draw the zoom rectangle to the QPainter. _draw_rect_callback needs
|
|
# to be called at the end of paintEvent.
|
|
if rect is not None:
|
|
def _draw_rect_callback(painter):
|
|
pen = QtGui.QPen(QtCore.Qt.black, 1 / self._dpi_ratio,
|
|
QtCore.Qt.DotLine)
|
|
painter.setPen(pen)
|
|
painter.drawRect(*(pt / self._dpi_ratio for pt in rect))
|
|
else:
|
|
def _draw_rect_callback(painter):
|
|
return
|
|
self._draw_rect_callback = _draw_rect_callback
|
|
self.update()
|
|
|
|
|
|
class MainWindow(QtWidgets.QMainWindow):
|
|
closing = QtCore.Signal()
|
|
|
|
def closeEvent(self, event):
|
|
self.closing.emit()
|
|
QtWidgets.QMainWindow.closeEvent(self, event)
|
|
|
|
|
|
class FigureManagerQT(FigureManagerBase):
|
|
"""
|
|
Attributes
|
|
----------
|
|
canvas : `FigureCanvas`
|
|
The FigureCanvas instance
|
|
num : int or str
|
|
The Figure number
|
|
toolbar : qt.QToolBar
|
|
The qt.QToolBar
|
|
window : qt.QMainWindow
|
|
The qt.QMainWindow
|
|
|
|
"""
|
|
|
|
def __init__(self, canvas, num):
|
|
FigureManagerBase.__init__(self, canvas, num)
|
|
self.canvas = canvas
|
|
self.window = MainWindow()
|
|
self.window.closing.connect(canvas.close_event)
|
|
self.window.closing.connect(self._widgetclosed)
|
|
|
|
self.window.setWindowTitle("Figure %d" % num)
|
|
image = os.path.join(matplotlib.rcParams['datapath'],
|
|
'images', 'matplotlib.svg')
|
|
self.window.setWindowIcon(QtGui.QIcon(image))
|
|
|
|
# Give the keyboard focus to the figure instead of the
|
|
# manager; StrongFocus accepts both tab and click to focus and
|
|
# will enable the canvas to process event w/o clicking.
|
|
# ClickFocus only takes the focus is the window has been
|
|
# clicked
|
|
# on. http://qt-project.org/doc/qt-4.8/qt.html#FocusPolicy-enum or
|
|
# http://doc.qt.digia.com/qt/qt.html#FocusPolicy-enum
|
|
self.canvas.setFocusPolicy(QtCore.Qt.StrongFocus)
|
|
self.canvas.setFocus()
|
|
|
|
self.window._destroying = False
|
|
|
|
self.toolmanager = self._get_toolmanager()
|
|
self.toolbar = self._get_toolbar(self.canvas, self.window)
|
|
self.statusbar = None
|
|
|
|
if self.toolmanager:
|
|
backend_tools.add_tools_to_manager(self.toolmanager)
|
|
if self.toolbar:
|
|
backend_tools.add_tools_to_container(self.toolbar)
|
|
self.statusbar = StatusbarQt(self.window, self.toolmanager)
|
|
|
|
if self.toolbar is not None:
|
|
self.window.addToolBar(self.toolbar)
|
|
if not self.toolmanager:
|
|
# add text label to status bar
|
|
statusbar_label = QtWidgets.QLabel()
|
|
self.window.statusBar().addWidget(statusbar_label)
|
|
self.toolbar.message.connect(statusbar_label.setText)
|
|
tbs_height = self.toolbar.sizeHint().height()
|
|
else:
|
|
tbs_height = 0
|
|
|
|
# resize the main window so it will display the canvas with the
|
|
# requested size:
|
|
cs = canvas.sizeHint()
|
|
sbs = self.window.statusBar().sizeHint()
|
|
height = cs.height() + tbs_height + sbs.height()
|
|
self.window.resize(cs.width(), height)
|
|
|
|
self.window.setCentralWidget(self.canvas)
|
|
|
|
if matplotlib.is_interactive():
|
|
self.window.show()
|
|
self.canvas.draw_idle()
|
|
|
|
self.window.raise_()
|
|
|
|
def full_screen_toggle(self):
|
|
if self.window.isFullScreen():
|
|
self.window.showNormal()
|
|
else:
|
|
self.window.showFullScreen()
|
|
|
|
def _widgetclosed(self):
|
|
if self.window._destroying:
|
|
return
|
|
self.window._destroying = True
|
|
try:
|
|
Gcf.destroy(self.num)
|
|
except AttributeError:
|
|
pass
|
|
# It seems that when the python session is killed,
|
|
# Gcf can get destroyed before the Gcf.destroy
|
|
# line is run, leading to a useless AttributeError.
|
|
|
|
def _get_toolbar(self, canvas, parent):
|
|
# must be inited after the window, drawingArea and figure
|
|
# attrs are set
|
|
if matplotlib.rcParams['toolbar'] == 'toolbar2':
|
|
toolbar = NavigationToolbar2QT(canvas, parent, False)
|
|
elif matplotlib.rcParams['toolbar'] == 'toolmanager':
|
|
toolbar = ToolbarQt(self.toolmanager, self.window)
|
|
else:
|
|
toolbar = None
|
|
return toolbar
|
|
|
|
def _get_toolmanager(self):
|
|
if matplotlib.rcParams['toolbar'] == 'toolmanager':
|
|
toolmanager = ToolManager(self.canvas.figure)
|
|
else:
|
|
toolmanager = None
|
|
return toolmanager
|
|
|
|
def resize(self, width, height):
|
|
# these are Qt methods so they return sizes in 'virtual' pixels
|
|
# so we do not need to worry about dpi scaling here.
|
|
extra_width = self.window.width() - self.canvas.width()
|
|
extra_height = self.window.height() - self.canvas.height()
|
|
self.window.resize(width+extra_width, height+extra_height)
|
|
|
|
def show(self):
|
|
self.window.show()
|
|
self.window.activateWindow()
|
|
self.window.raise_()
|
|
|
|
def destroy(self, *args):
|
|
# check for qApp first, as PySide deletes it in its atexit handler
|
|
if QtWidgets.QApplication.instance() is None:
|
|
return
|
|
if self.window._destroying:
|
|
return
|
|
self.window._destroying = True
|
|
if self.toolbar:
|
|
self.toolbar.destroy()
|
|
self.window.close()
|
|
|
|
def get_window_title(self):
|
|
return self.window.windowTitle()
|
|
|
|
def set_window_title(self, title):
|
|
self.window.setWindowTitle(title)
|
|
|
|
|
|
class NavigationToolbar2QT(NavigationToolbar2, QtWidgets.QToolBar):
|
|
message = QtCore.Signal(str)
|
|
|
|
def __init__(self, canvas, parent, coordinates=True):
|
|
""" coordinates: should we show the coordinates on the right? """
|
|
self.canvas = canvas
|
|
self.parent = parent
|
|
self.coordinates = coordinates
|
|
self._actions = {}
|
|
"""A mapping of toolitem method names to their QActions"""
|
|
|
|
QtWidgets.QToolBar.__init__(self, parent)
|
|
NavigationToolbar2.__init__(self, canvas)
|
|
|
|
def _icon(self, name):
|
|
if is_pyqt5():
|
|
name = name.replace('.png', '_large.png')
|
|
pm = QtGui.QPixmap(os.path.join(self.basedir, name))
|
|
if hasattr(pm, 'setDevicePixelRatio'):
|
|
pm.setDevicePixelRatio(self.canvas._dpi_ratio)
|
|
return QtGui.QIcon(pm)
|
|
|
|
def _init_toolbar(self):
|
|
self.basedir = os.path.join(matplotlib.rcParams['datapath'], 'images')
|
|
|
|
for text, tooltip_text, image_file, callback in self.toolitems:
|
|
if text is None:
|
|
self.addSeparator()
|
|
else:
|
|
a = self.addAction(self._icon(image_file + '.png'),
|
|
text, getattr(self, callback))
|
|
self._actions[callback] = a
|
|
if callback in ['zoom', 'pan']:
|
|
a.setCheckable(True)
|
|
if tooltip_text is not None:
|
|
a.setToolTip(tooltip_text)
|
|
if text == 'Subplots':
|
|
a = self.addAction(self._icon("qt4_editor_options.png"),
|
|
'Customize', self.edit_parameters)
|
|
a.setToolTip('Edit axis, curve and image parameters')
|
|
|
|
# Add the x,y location widget at the right side of the toolbar
|
|
# The stretch factor is 1 which means any resizing of the toolbar
|
|
# will resize this label instead of the buttons.
|
|
if self.coordinates:
|
|
self.locLabel = QtWidgets.QLabel("", self)
|
|
self.locLabel.setAlignment(
|
|
QtCore.Qt.AlignRight | QtCore.Qt.AlignTop)
|
|
self.locLabel.setSizePolicy(
|
|
QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding,
|
|
QtWidgets.QSizePolicy.Ignored))
|
|
labelAction = self.addWidget(self.locLabel)
|
|
labelAction.setVisible(True)
|
|
|
|
# Esthetic adjustments - we need to set these explicitly in PyQt5
|
|
# otherwise the layout looks different - but we don't want to set it if
|
|
# not using HiDPI icons otherwise they look worse than before.
|
|
if is_pyqt5() and self.canvas._dpi_ratio > 1:
|
|
self.setIconSize(QtCore.QSize(24, 24))
|
|
self.layout().setSpacing(12)
|
|
|
|
@cbook.deprecated("3.1")
|
|
@property
|
|
def buttons(self):
|
|
return {}
|
|
|
|
@cbook.deprecated("3.1")
|
|
@property
|
|
def adj_window(self):
|
|
return None
|
|
|
|
def sizeHint(self):
|
|
size = super().sizeHint()
|
|
if is_pyqt5() and self.canvas._dpi_ratio > 1:
|
|
# For some reason, self.setMinimumHeight doesn't seem to carry over
|
|
# to the actual sizeHint, so override it instead in order to make
|
|
# the aesthetic adjustments noted above.
|
|
size.setHeight(max(48, size.height()))
|
|
return size
|
|
|
|
def edit_parameters(self):
|
|
axes = self.canvas.figure.get_axes()
|
|
if not axes:
|
|
QtWidgets.QMessageBox.warning(
|
|
self.parent, "Error", "There are no axes to edit.")
|
|
return
|
|
elif len(axes) == 1:
|
|
ax, = axes
|
|
else:
|
|
titles = [
|
|
ax.get_label() or
|
|
ax.get_title() or
|
|
" - ".join(filter(None, [ax.get_xlabel(), ax.get_ylabel()])) or
|
|
f"<anonymous {type(ax).__name__}>"
|
|
for ax in axes]
|
|
duplicate_titles = [
|
|
title for title in titles if titles.count(title) > 1]
|
|
for i, ax in enumerate(axes):
|
|
if titles[i] in duplicate_titles:
|
|
titles[i] += f" (id: {id(ax):#x})" # Deduplicate titles.
|
|
item, ok = QtWidgets.QInputDialog.getItem(
|
|
self.parent, 'Customize', 'Select axes:', titles, 0, False)
|
|
if not ok:
|
|
return
|
|
ax = axes[titles.index(item)]
|
|
figureoptions.figure_edit(ax, self)
|
|
|
|
def _update_buttons_checked(self):
|
|
# sync button checkstates to match active mode
|
|
self._actions['pan'].setChecked(self._active == 'PAN')
|
|
self._actions['zoom'].setChecked(self._active == 'ZOOM')
|
|
|
|
def pan(self, *args):
|
|
super().pan(*args)
|
|
self._update_buttons_checked()
|
|
|
|
def zoom(self, *args):
|
|
super().zoom(*args)
|
|
self._update_buttons_checked()
|
|
|
|
def set_message(self, s):
|
|
self.message.emit(s)
|
|
if self.coordinates:
|
|
self.locLabel.setText(s)
|
|
|
|
def set_cursor(self, cursor):
|
|
self.canvas.setCursor(cursord[cursor])
|
|
|
|
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.drawRectangle(rect)
|
|
|
|
def remove_rubberband(self):
|
|
self.canvas.drawRectangle(None)
|
|
|
|
def configure_subplots(self):
|
|
image = os.path.join(matplotlib.rcParams['datapath'],
|
|
'images', 'matplotlib.png')
|
|
dia = SubplotToolQt(self.canvas.figure, self.canvas.parent())
|
|
dia.setWindowIcon(QtGui.QIcon(image))
|
|
dia.exec_()
|
|
|
|
def save_figure(self, *args):
|
|
filetypes = self.canvas.get_supported_filetypes_grouped()
|
|
sorted_filetypes = sorted(filetypes.items())
|
|
default_filetype = self.canvas.get_default_filetype()
|
|
|
|
startpath = os.path.expanduser(
|
|
matplotlib.rcParams['savefig.directory'])
|
|
start = os.path.join(startpath, self.canvas.get_default_filename())
|
|
filters = []
|
|
selectedFilter = None
|
|
for name, exts in sorted_filetypes:
|
|
exts_list = " ".join(['*.%s' % ext for ext in exts])
|
|
filter = '%s (%s)' % (name, exts_list)
|
|
if default_filetype in exts:
|
|
selectedFilter = filter
|
|
filters.append(filter)
|
|
filters = ';;'.join(filters)
|
|
|
|
fname, filter = _getSaveFileName(self.canvas.parent(),
|
|
"Choose a filename to save to",
|
|
start, filters, selectedFilter)
|
|
if fname:
|
|
# Save dir for next time, unless empty str (i.e., use cwd).
|
|
if startpath != "":
|
|
matplotlib.rcParams['savefig.directory'] = (
|
|
os.path.dirname(fname))
|
|
try:
|
|
self.canvas.figure.savefig(fname)
|
|
except Exception as e:
|
|
QtWidgets.QMessageBox.critical(
|
|
self, "Error saving file", str(e),
|
|
QtWidgets.QMessageBox.Ok, QtWidgets.QMessageBox.NoButton)
|
|
|
|
def set_history_buttons(self):
|
|
can_backward = self._nav_stack._pos > 0
|
|
can_forward = self._nav_stack._pos < len(self._nav_stack._elements) - 1
|
|
if 'back' in self._actions:
|
|
self._actions['back'].setEnabled(can_backward)
|
|
if 'forward' in self._actions:
|
|
self._actions['forward'].setEnabled(can_forward)
|
|
|
|
|
|
class SubplotToolQt(UiSubplotTool):
|
|
def __init__(self, targetfig, parent):
|
|
UiSubplotTool.__init__(self, None)
|
|
|
|
self._figure = targetfig
|
|
|
|
for lower, higher in [("bottom", "top"), ("left", "right")]:
|
|
self._widgets[lower].valueChanged.connect(
|
|
lambda val: self._widgets[higher].setMinimum(val + .001))
|
|
self._widgets[higher].valueChanged.connect(
|
|
lambda val: self._widgets[lower].setMaximum(val - .001))
|
|
|
|
self._attrs = ["top", "bottom", "left", "right", "hspace", "wspace"]
|
|
self._defaults = {attr: vars(self._figure.subplotpars)[attr]
|
|
for attr in self._attrs}
|
|
|
|
# Set values after setting the range callbacks, but before setting up
|
|
# the redraw callbacks.
|
|
self._reset()
|
|
|
|
for attr in self._attrs:
|
|
self._widgets[attr].valueChanged.connect(self._on_value_changed)
|
|
for action, method in [("Export values", self._export_values),
|
|
("Tight layout", self._tight_layout),
|
|
("Reset", self._reset),
|
|
("Close", self.close)]:
|
|
self._widgets[action].clicked.connect(method)
|
|
|
|
def _export_values(self):
|
|
# Explicitly round to 3 decimals (which is also the spinbox precision)
|
|
# to avoid numbers of the form 0.100...001.
|
|
dialog = QtWidgets.QDialog()
|
|
layout = QtWidgets.QVBoxLayout()
|
|
dialog.setLayout(layout)
|
|
text = QtWidgets.QPlainTextEdit()
|
|
text.setReadOnly(True)
|
|
layout.addWidget(text)
|
|
text.setPlainText(
|
|
",\n".join("{}={:.3}".format(attr, self._widgets[attr].value())
|
|
for attr in self._attrs))
|
|
# Adjust the height of the text widget to fit the whole text, plus
|
|
# some padding.
|
|
size = text.maximumSize()
|
|
size.setHeight(
|
|
QtGui.QFontMetrics(text.document().defaultFont())
|
|
.size(0, text.toPlainText()).height() + 20)
|
|
text.setMaximumSize(size)
|
|
dialog.exec_()
|
|
|
|
def _on_value_changed(self):
|
|
self._figure.subplots_adjust(**{attr: self._widgets[attr].value()
|
|
for attr in self._attrs})
|
|
self._figure.canvas.draw_idle()
|
|
|
|
def _tight_layout(self):
|
|
self._figure.tight_layout()
|
|
for attr in self._attrs:
|
|
widget = self._widgets[attr]
|
|
widget.blockSignals(True)
|
|
widget.setValue(vars(self._figure.subplotpars)[attr])
|
|
widget.blockSignals(False)
|
|
self._figure.canvas.draw_idle()
|
|
|
|
def _reset(self):
|
|
for attr, value in self._defaults.items():
|
|
self._widgets[attr].setValue(value)
|
|
|
|
|
|
class ToolbarQt(ToolContainerBase, QtWidgets.QToolBar):
|
|
def __init__(self, toolmanager, parent):
|
|
ToolContainerBase.__init__(self, toolmanager)
|
|
QtWidgets.QToolBar.__init__(self, parent)
|
|
self._toolitems = {}
|
|
self._groups = {}
|
|
|
|
@property
|
|
def _icon_extension(self):
|
|
if is_pyqt5():
|
|
return '_large.png'
|
|
return '.png'
|
|
|
|
def add_toolitem(
|
|
self, name, group, position, image_file, description, toggle):
|
|
|
|
button = QtWidgets.QToolButton(self)
|
|
button.setIcon(self._icon(image_file))
|
|
button.setText(name)
|
|
if description:
|
|
button.setToolTip(description)
|
|
|
|
def handler():
|
|
self.trigger_tool(name)
|
|
if toggle:
|
|
button.setCheckable(True)
|
|
button.toggled.connect(handler)
|
|
else:
|
|
button.clicked.connect(handler)
|
|
|
|
self._toolitems.setdefault(name, [])
|
|
self._add_to_group(group, name, button, position)
|
|
self._toolitems[name].append((button, handler))
|
|
|
|
def _add_to_group(self, group, name, button, position):
|
|
gr = self._groups.get(group, [])
|
|
if not gr:
|
|
sep = self.addSeparator()
|
|
gr.append(sep)
|
|
before = gr[position]
|
|
widget = self.insertWidget(before, button)
|
|
gr.insert(position, widget)
|
|
self._groups[group] = gr
|
|
|
|
def _icon(self, name):
|
|
pm = QtGui.QPixmap(name)
|
|
if hasattr(pm, 'setDevicePixelRatio'):
|
|
pm.setDevicePixelRatio(self.toolmanager.canvas._dpi_ratio)
|
|
return QtGui.QIcon(pm)
|
|
|
|
def toggle_toolitem(self, name, toggled):
|
|
if name not in self._toolitems:
|
|
return
|
|
for button, handler in self._toolitems[name]:
|
|
button.toggled.disconnect(handler)
|
|
button.setChecked(toggled)
|
|
button.toggled.connect(handler)
|
|
|
|
def remove_toolitem(self, name):
|
|
for button, handler in self._toolitems[name]:
|
|
button.setParent(None)
|
|
del self._toolitems[name]
|
|
|
|
|
|
class StatusbarQt(StatusbarBase, QtWidgets.QLabel):
|
|
def __init__(self, window, *args, **kwargs):
|
|
StatusbarBase.__init__(self, *args, **kwargs)
|
|
QtWidgets.QLabel.__init__(self)
|
|
window.statusBar().addWidget(self)
|
|
|
|
def set_message(self, s):
|
|
self.setText(s)
|
|
|
|
|
|
class ConfigureSubplotsQt(backend_tools.ConfigureSubplotsBase):
|
|
def trigger(self, *args):
|
|
NavigationToolbar2QT.configure_subplots(
|
|
self._make_classic_style_pseudo_toolbar())
|
|
|
|
|
|
class SaveFigureQt(backend_tools.SaveFigureBase):
|
|
def trigger(self, *args):
|
|
NavigationToolbar2QT.save_figure(
|
|
self._make_classic_style_pseudo_toolbar())
|
|
|
|
|
|
class SetCursorQt(backend_tools.SetCursorBase):
|
|
def set_cursor(self, cursor):
|
|
NavigationToolbar2QT.set_cursor(
|
|
self._make_classic_style_pseudo_toolbar(), cursor)
|
|
|
|
|
|
class RubberbandQt(backend_tools.RubberbandBase):
|
|
def draw_rubberband(self, x0, y0, x1, y1):
|
|
NavigationToolbar2QT.draw_rubberband(
|
|
self._make_classic_style_pseudo_toolbar(), None, x0, y0, x1, y1)
|
|
|
|
def remove_rubberband(self):
|
|
NavigationToolbar2QT.remove_rubberband(
|
|
self._make_classic_style_pseudo_toolbar())
|
|
|
|
|
|
class HelpQt(backend_tools.ToolHelpBase):
|
|
def trigger(self, *args):
|
|
QtWidgets.QMessageBox.information(None, "Help", self._get_help_html())
|
|
|
|
|
|
class ToolCopyToClipboardQT(backend_tools.ToolCopyToClipboardBase):
|
|
def trigger(self, *args, **kwargs):
|
|
pixmap = self.canvas.grab()
|
|
qApp.clipboard().setPixmap(pixmap)
|
|
|
|
|
|
backend_tools.ToolSaveFigure = SaveFigureQt
|
|
backend_tools.ToolConfigureSubplots = ConfigureSubplotsQt
|
|
backend_tools.ToolSetCursor = SetCursorQt
|
|
backend_tools.ToolRubberband = RubberbandQt
|
|
backend_tools.ToolHelp = HelpQt
|
|
backend_tools.ToolCopyToClipboard = ToolCopyToClipboardQT
|
|
|
|
|
|
@cbook.deprecated("3.0")
|
|
def error_msg_qt(msg, parent=None):
|
|
if not isinstance(msg, str):
|
|
msg = ','.join(map(str, msg))
|
|
|
|
QtWidgets.QMessageBox.warning(None, "Matplotlib",
|
|
msg, QtGui.QMessageBox.Ok)
|
|
|
|
|
|
@cbook.deprecated("3.0")
|
|
def exception_handler(type, value, tb):
|
|
"""Handle uncaught exceptions
|
|
It does not catch SystemExit
|
|
"""
|
|
msg = ''
|
|
# get the filename attribute if available (for IOError)
|
|
if hasattr(value, 'filename') and value.filename is not None:
|
|
msg = value.filename + ': '
|
|
if hasattr(value, 'strerror') and value.strerror is not None:
|
|
msg += value.strerror
|
|
else:
|
|
msg += str(value)
|
|
|
|
if len(msg):
|
|
error_msg_qt(msg)
|
|
|
|
|
|
@_Backend.export
|
|
class _BackendQT5(_Backend):
|
|
required_interactive_framework = "qt5"
|
|
FigureCanvas = FigureCanvasQT
|
|
FigureManager = FigureManagerQT
|
|
|
|
@staticmethod
|
|
def trigger_manager_draw(manager):
|
|
manager.canvas.draw_idle()
|
|
|
|
@staticmethod
|
|
def mainloop():
|
|
old_signal = signal.getsignal(signal.SIGINT)
|
|
# allow SIGINT exceptions to close the plot window.
|
|
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
try:
|
|
qApp.exec_()
|
|
finally:
|
|
# reset the SIGINT exception handler
|
|
signal.signal(signal.SIGINT, old_signal)
|