From 953913f5dfeb7c628b1b7dcbaeb7eff02a3740c1 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sun, 3 Jan 2021 17:01:55 +0100 Subject: [PATCH 01/11] Support for PyQt6/PySide6. Currently these must be selected via `QT_API=pyqt6`/`QT_API=pyside6`. Note that I didn't create a separate backend_qt6agg (and backend_qt6cairo, and mplcairo.qt6...) as it seems preferable to instead move towards a single backend_qtagg and allow selection of the actual qt binding via the orthogonal QT_API mechanism. Most of the work is just handling attributes that moved out of the Qt namespace. --- lib/matplotlib/backends/backend_qt5.py | 239 ++++++++++-------- lib/matplotlib/backends/backend_qt5agg.py | 14 +- lib/matplotlib/backends/backend_qt5cairo.py | 16 +- lib/matplotlib/backends/qt_compat.py | 81 ++++-- .../backends/qt_editor/_formlayout.py | 29 ++- lib/matplotlib/tests/test_backend_qt.py | 15 +- 6 files changed, 244 insertions(+), 150 deletions(-) diff --git a/lib/matplotlib/backends/backend_qt5.py b/lib/matplotlib/backends/backend_qt5.py index f8df0a96240f..484a2b049f16 100644 --- a/lib/matplotlib/backends/backend_qt5.py +++ b/lib/matplotlib/backends/backend_qt5.py @@ -1,4 +1,5 @@ import functools +import operator import os import signal import sys @@ -14,74 +15,86 @@ from . import qt_compat from .qt_compat import ( QtCore, QtGui, QtWidgets, __version__, QT_API, + _enum, _to_int, _devicePixelRatioF, _isdeleted, _setDevicePixelRatio, ) 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: 'meta', - QtCore.Qt.Key_Super_L: 'super', - QtCore.Qt.Key_Super_R: 'super', - QtCore.Qt.Key_CapsLock: 'caps_lock', - 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', } -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', - }) +# SPECIAL_KEYS are Qt::Key that do *not* return their unicode name +# instead they have manually specified names. +SPECIAL_KEYS = { + _to_int(getattr(_enum("QtCore.Qt.Key"), k)): v for k, v in [ + ("Key_Escape", "escape"), + ("Key_Tab", "tab"), + ("Key_Backspace", "backspace"), + ("Key_Return", "enter"), + ("Key_Enter", "enter"), + ("Key_Insert", "insert"), + ("Key_Delete", "delete"), + ("Key_Pause", "pause"), + ("Key_SysReq", "sysreq"), + ("Key_Clear", "clear"), + ("Key_Home", "home"), + ("Key_End", "end"), + ("Key_Left", "left"), + ("Key_Up", "up"), + ("Key_Right", "right"), + ("Key_Down", "down"), + ("Key_PageUp", "pageup"), + ("Key_PageDown", "pagedown"), + ("Key_Shift", "shift"), + # In OSX, the control and super (aka cmd/apple) keys are switched. + ("Key_Control", "control" if sys.platform != "darwin" else "cmd"), + ("Key_Meta", "meta" if sys.platform != "darwin" else "control"), + ("Key_Alt", "alt"), + ("Key_CapsLock", "caps_lock"), + ("Key_F1", "f1"), + ("Key_F2", "f2"), + ("Key_F3", "f3"), + ("Key_F4", "f4"), + ("Key_F5", "f5"), + ("Key_F6", "f6"), + ("Key_F7", "f7"), + ("Key_F8", "f8"), + ("Key_F9", "f9"), + ("Key_F10", "f10"), + ("Key_F10", "f11"), + ("Key_F12", "f12"), + ("Key_Super_L", "super"), + ("Key_Super_R", "super"), + ] +} # Define which modifier keys are collected on keyboard events. -# Elements are (Modifier Flag, Qt Key) tuples. +# Elements are (Qt::KeyboardModifiers, Qt::Key) tuples. # Order determines the modifier order (ctrl+alt+...) reported by Matplotlib. _MODIFIER_KEYS = [ - (QtCore.Qt.ControlModifier, QtCore.Qt.Key_Control), - (QtCore.Qt.AltModifier, QtCore.Qt.Key_Alt), - (QtCore.Qt.ShiftModifier, QtCore.Qt.Key_Shift), - (QtCore.Qt.MetaModifier, QtCore.Qt.Key_Meta), + (_to_int(getattr(_enum("QtCore.Qt.KeyboardModifiers"), mod)), + _to_int(getattr(_enum("QtCore.Qt.Key"), key))) + for mod, key in [ + ("ControlModifier", "Key_Control"), + ("AltModifier", "Key_Alt"), + ("ShiftModifier", "Key_Shift"), + ("MetaModifier", "Key_Meta"), + ] ] -cursord = { # deprecated in Matplotlib 3.5. - 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, - cursors.RESIZE_HORIZONTAL: QtCore.Qt.SizeHorCursor, - cursors.RESIZE_VERTICAL: QtCore.Qt.SizeVerCursor, +cursord = { + k: getattr(_enum("QtCore.Qt.CursorShape"), v) for k, v in [ + (cursors.MOVE, "SizeAllCursor"), + (cursors.HAND, "PointingHandCursor"), + (cursors.POINTER, "ArrowCursor"), + (cursors.SELECT_REGION, "CrossCursor"), + (cursors.WAIT, "WaitCursor"), + (cursors.RESIZE_HORIZONTAL, "SizeHorCursor"), + (cursors.RESIZE_VERTICAL, "SizeVerCursor"), + ] } +SUPER = 0 # Deprecated. +ALT = 1 # Deprecated. +CTRL = 2 # Deprecated. +SHIFT = 3 # Deprecated. +MODIFIER_KEYS = [ # Deprecated. + (SPECIAL_KEYS[key], mod, key) for mod, key in _MODIFIER_KEYS] # make place holder @@ -104,12 +117,12 @@ def _create_qApp(): try: QtWidgets.QApplication.setAttribute( QtCore.Qt.AA_EnableHighDpiScaling) - except AttributeError: # Attribute only exists for Qt>=5.6. + except AttributeError: # Only for Qt>=5.6, <6. pass try: QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy( QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough) - except AttributeError: # Added in Qt>=5.14. + except AttributeError: # Only for Qt>=5.14. pass qApp = QtWidgets.QApplication(["matplotlib"]) qApp.lastWindowClosed.connect(qApp.quit) @@ -118,7 +131,7 @@ def _create_qApp(): qApp = app try: - qApp.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps) + qApp.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps) # Only for Qt<6. except AttributeError: pass @@ -128,19 +141,19 @@ def _allow_super_init(__init__): Decorator for ``__init__`` to allow ``super().__init__`` on PySide2. """ - if QT_API == "PyQt5": + if QT_API in ["PyQt5", "PyQt6"]: return __init__ else: - # To work around lack of cooperative inheritance in PySide2, when - # calling FigureCanvasQT.__init__, we temporarily patch + # To work around lack of cooperative inheritance in PySide2 and + # PySide6, 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 PySide2, sip or Shiboken - # packages), and manually call its `__init__`, once again passing the - # additional arguments. + # inheritance (i.e., not defined by the PyQt4 or sip, or PySide{,2,6} + # or Shiboken packages), and manually call its `__init__`, once again + # passing the additional arguments. qwidget_init = QtWidgets.QWidget.__init__ @@ -150,7 +163,8 @@ def cooperative_qwidget_init(self, *args, **kwargs): next_coop_init = next( cls for cls in mro[mro.index(QtWidgets.QWidget) + 1:] if cls.__module__.split(".")[0] not in [ - "sip", "PySide2", "Shiboken"]) + "PySide2", "PySide6", "Shiboken", + ]) next_coop_init.__init__(self, *args, **kwargs) @functools.wraps(__init__) @@ -195,13 +209,15 @@ class FigureCanvasQT(QtWidgets.QWidget, FigureCanvasBase): required_interactive_framework = "qt5" _timer_cls = TimerQT - # 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, - } + buttond = { + getattr(_enum("QtCore.Qt.MouseButtons"), k): v for k, v in [ + ("LeftButton", MouseButton.LEFT), + ("RightButton", MouseButton.RIGHT), + ("MiddleButton", MouseButton.MIDDLE), + ("XButton1", MouseButton.BACK), + ("XButton2", MouseButton.FORWARD), + ] + } @_allow_super_init def __init__(self, figure=None): @@ -212,11 +228,12 @@ def __init__(self, figure=None): self._is_drawing = False self._draw_rect_callback = lambda painter: None - self.setAttribute(QtCore.Qt.WA_OpaquePaintEvent) + self.setAttribute( + _enum("QtCore.Qt.WidgetAttribute").WA_OpaquePaintEvent) self.setMouseTracking(True) self.resize(*self.get_width_height()) - palette = QtGui.QPalette(QtCore.Qt.white) + palette = QtGui.QPalette(QtGui.QColor("white")) self.setPalette(palette) def _update_pixel_ratio(self): @@ -246,13 +263,16 @@ def set_cursor(self, cursor): self.setCursor(_api.check_getitem(cursord, cursor=cursor)) def enterEvent(self, event): - x, y = self.mouseEventCoords(event.pos()) + x, y = self.mouseEventCoords(self._get_position(event)) FigureCanvasBase.enter_notify_event(self, guiEvent=event, xy=(x, y)) def leaveEvent(self, event): QtWidgets.QApplication.restoreOverrideCursor() FigureCanvasBase.leave_notify_event(self, guiEvent=event) + _get_position = operator.methodcaller( + "position" if QT_API in ["PyQt6", "PySide6"] else "pos") + def mouseEventCoords(self, pos): """ Calculate mouse coordinates in physical pixels. @@ -269,14 +289,14 @@ def mouseEventCoords(self, pos): return x * self.device_pixel_ratio, y * self.device_pixel_ratio def mousePressEvent(self, event): - x, y = self.mouseEventCoords(event.pos()) + x, y = self.mouseEventCoords(self._get_position(event)) 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()) + x, y = self.mouseEventCoords(self._get_position(event)) button = self.buttond.get(event.button()) if button is not None: FigureCanvasBase.button_press_event(self, x, y, @@ -284,18 +304,18 @@ def mouseDoubleClickEvent(self, event): guiEvent=event) def mouseMoveEvent(self, event): - x, y = self.mouseEventCoords(event) + x, y = self.mouseEventCoords(self._get_position(event)) FigureCanvasBase.motion_notify_event(self, x, y, guiEvent=event) def mouseReleaseEvent(self, event): - x, y = self.mouseEventCoords(event) + x, y = self.mouseEventCoords(self._get_position(event)) button = self.buttond.get(event.button()) if button is not None: FigureCanvasBase.button_release_event(self, x, y, button, guiEvent=event) def wheelEvent(self, event): - x, y = self.mouseEventCoords(event) + x, y = self.mouseEventCoords(self._get_position(event)) # from QWheelEvent::delta doc if event.pixelDelta().x() == 0 and event.pixelDelta().y() == 0: steps = event.angleDelta().y() / 120 @@ -316,8 +336,12 @@ def keyReleaseEvent(self, event): FigureCanvasBase.key_release_event(self, key, guiEvent=event) def resizeEvent(self, event): + frame = sys._getframe() + if frame.f_code is frame.f_back.f_code: # Prevent PyQt6 recursion. + return w = event.size().width() * self.device_pixel_ratio h = event.size().height() * self.device_pixel_ratio + dpival = self.figure.dpi winch = w / dpival hinch = h / dpival @@ -336,7 +360,7 @@ def minumumSizeHint(self): def _get_key(self, event): event_key = event.key() - event_mods = int(event.modifiers()) # actually a bitmask + event_mods = _to_int(event.modifiers()) # actually a bitmask # get names of the pressed modifier keys # 'control' is named 'control' when a standalone key, but 'ctrl' when a @@ -381,7 +405,7 @@ def start_event_loop(self, timeout=0): if timeout > 0: timer = QtCore.QTimer.singleShot(int(timeout * 1000), event_loop.quit) - event_loop.exec_() + qt_compat._exec(event_loop) def stop_event_loop(self, event=None): # docstring inherited @@ -440,10 +464,16 @@ def drawRectangle(self, rect): x1 = x0 + w y1 = y0 + h def _draw_rect_callback(painter): - pen = QtGui.QPen(QtCore.Qt.black, 1 / self.device_pixel_ratio) + pen = QtGui.QPen( + QtGui.QColor("black"), + 1 / self.device_pixel_ratio + ) + pen.setDashPattern([3, 3]) for color, offset in [ - (QtCore.Qt.black, 0), (QtCore.Qt.white, 3)]: + (QtGui.QColor("black"), 0), + (QtGui.QColor("white"), 3), + ]: pen.setDashOffset(offset) pen.setColor(color) painter.setPen(pen) @@ -523,7 +553,7 @@ def __init__(self, canvas, num): # StrongFocus accepts both tab and click to focus and will enable the # canvas to process event without clicking. # https://doc.qt.io/qt-5/qt.html#FocusPolicy-enum - self.canvas.setFocusPolicy(QtCore.Qt.StrongFocus) + self.canvas.setFocusPolicy(_enum("QtCore.Qt.FocusPolicy").StrongFocus) self.canvas.setFocus() self.window.raise_() @@ -603,7 +633,8 @@ def __init__(self, canvas, parent, coordinates=True): """coordinates: should we show the coordinates on the right?""" QtWidgets.QToolBar.__init__(self, parent) self.setAllowedAreas( - QtCore.Qt.TopToolBarArea | QtCore.Qt.BottomToolBarArea) + _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea + | _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea) self.coordinates = coordinates self._actions = {} # mapping of toolitem method names to QActions. @@ -627,10 +658,12 @@ def __init__(self, canvas, parent, coordinates=True): if self.coordinates: self.locLabel = QtWidgets.QLabel("", self) self.locLabel.setAlignment( - QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) - self.locLabel.setSizePolicy( - QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.Ignored)) + _enum("QtCore.Qt.Alignment").AlignRight + | _enum("QtCore.Qt.Alignment").AlignVCenter) + self.locLabel.setSizePolicy(QtWidgets.QSizePolicy( + _enum("QtWidgets.QSizePolicy.Policy").Expanding, + _enum("QtWidgets.QSizePolicy.Policy").Ignored, + )) labelAction = self.addWidget(self.locLabel) labelAction.setVisible(True) @@ -646,8 +679,9 @@ def _icon(self, name): _setDevicePixelRatio(pm, _devicePixelRatioF(self)) if self.palette().color(self.backgroundRole()).value() < 128: icon_color = self.palette().color(self.foregroundRole()) - mask = pm.createMaskFromColor(QtGui.QColor('black'), - QtCore.Qt.MaskOutColor) + mask = pm.createMaskFromColor( + QtGui.QColor('black'), + _enum("QtCore.Qt.MaskMode").MaskOutColor) pm.fill(icon_color) pm.setMask(mask) return QtGui.QIcon(pm) @@ -856,13 +890,16 @@ def __init__(self, toolmanager, parent): ToolContainerBase.__init__(self, toolmanager) QtWidgets.QToolBar.__init__(self, parent) self.setAllowedAreas( - QtCore.Qt.TopToolBarArea | QtCore.Qt.BottomToolBarArea) + _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea + | _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea) message_label = QtWidgets.QLabel("") message_label.setAlignment( - QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) - message_label.setSizePolicy( - QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.Ignored)) + _enum("QtCore.Qt.Alignment").AlignRight + | _enum("QtCore.Qt.Alignment").AlignVCenter) + message_label.setSizePolicy(QtWidgets.QSizePolicy( + _enum("QtWidgets.QSizePolicy.Policy").Expanding, + _enum("QtWidgets.QSizePolicy.Policy").Ignored, + )) self._message_action = self.addWidget(message_label) self._toolitems = {} self._groups = {} @@ -976,7 +1013,7 @@ def mainloop(): if is_python_signal_handler: signal.signal(signal.SIGINT, signal.SIG_DFL) try: - qApp.exec_() + qt_compat._exec(qApp) finally: # reset the SIGINT exception handler if is_python_signal_handler: diff --git a/lib/matplotlib/backends/backend_qt5agg.py b/lib/matplotlib/backends/backend_qt5agg.py index 3c5de72f7697..a900fd16b1a3 100644 --- a/lib/matplotlib/backends/backend_qt5agg.py +++ b/lib/matplotlib/backends/backend_qt5agg.py @@ -11,7 +11,7 @@ from .backend_qt5 import ( QtCore, QtGui, QtWidgets, _BackendQT5, FigureCanvasQT, FigureManagerQT, NavigationToolbar2QT, backend_version) -from .qt_compat import QT_API, _setDevicePixelRatio +from .qt_compat import QT_API, _enum, _setDevicePixelRatio class FigureCanvasQTAgg(FigureCanvasAgg, FigureCanvasQT): @@ -59,14 +59,20 @@ def paintEvent(self, event): # clear the widget canvas painter.eraseRect(rect) - qimage = QtGui.QImage(buf, buf.shape[1], buf.shape[0], - QtGui.QImage.Format_ARGB32_Premultiplied) + if QT_API == "PyQt6": + from PyQt6 import sip + ptr = sip.voidptr(buf) + else: + ptr = buf + qimage = QtGui.QImage( + ptr, buf.shape[1], buf.shape[0], + _enum("QtGui.QImage.Format").Format_ARGB32_Premultiplied) _setDevicePixelRatio(qimage, self.device_pixel_ratio) # set origin using original QT coordinates origin = QtCore.QPoint(rect.left(), rect.top()) painter.drawImage(origin, qimage) # Adjust the buf reference count to work around a memory - # leak bug in QImage under PySide on Python 3. + # leak bug in QImage under PySide. if QT_API in ('PySide', 'PySide2'): ctypes.c_long.from_address(id(buf)).value = 1 diff --git a/lib/matplotlib/backends/backend_qt5cairo.py b/lib/matplotlib/backends/backend_qt5cairo.py index e15e0d858ad8..010c6aabb9b0 100644 --- a/lib/matplotlib/backends/backend_qt5cairo.py +++ b/lib/matplotlib/backends/backend_qt5cairo.py @@ -2,7 +2,7 @@ from .backend_cairo import cairo, FigureCanvasCairo, RendererCairo from .backend_qt5 import QtCore, QtGui, _BackendQT5, FigureCanvasQT -from .qt_compat import QT_API, _setDevicePixelRatio +from .qt_compat import QT_API, _enum, _setDevicePixelRatio class FigureCanvasQTCairo(FigureCanvasQT, FigureCanvasCairo): @@ -25,11 +25,17 @@ def paintEvent(self, event): self._renderer.set_width_height(width, height) self.figure.draw(self._renderer) buf = self._renderer.gc.ctx.get_target().get_data() - qimage = QtGui.QImage(buf, width, height, - QtGui.QImage.Format_ARGB32_Premultiplied) + if QT_API == "PyQt6": + import sip + ptr = sip.voidptr(buf) + else: + ptr = buf + qimage = QtGui.QImage( + ptr, width, height, + _enum("QtGui.QImage.Format").Format_ARGB32_Premultiplied) # Adjust the buf reference count to work around a memory leak bug in - # QImage under PySide on Python 3. - if QT_API == 'PySide': + # QImage under PySide. + if QT_API in ('PySide', 'PySide2'): ctypes.c_long.from_address(id(buf)).value = 1 _setDevicePixelRatio(qimage, self.device_pixel_ratio) painter = QtGui.QPainter(self) diff --git a/lib/matplotlib/backends/qt_compat.py b/lib/matplotlib/backends/qt_compat.py index f31db2e98fc6..f5a59fde1b8d 100644 --- a/lib/matplotlib/backends/qt_compat.py +++ b/lib/matplotlib/backends/qt_compat.py @@ -2,8 +2,8 @@ Qt binding and backend selector. The selection logic is as follows: -- if any of PyQt5, or PySide2 have already been imported (checked in that - order), use it; +- if any of PyQt6, PySide6, PyQt5, or PySide2 have already been + imported (checked in that order), use it; - otherwise, if the QT_API environment variable (used by Enthought) is set, use it to determine which binding to use (but do not change the backend based on it; i.e. if the Qt5Agg backend is requested but QT_API is set to "pyqt4", @@ -11,6 +11,8 @@ - otherwise, use whatever the rcParams indicate. """ +import functools +import operator import os import platform import sys @@ -20,6 +22,8 @@ import matplotlib as mpl +QT_API_PYQT6 = "PyQt6" +QT_API_PYSIDE6 = "PySide6" QT_API_PYQT5 = "PyQt5" QT_API_PYSIDE2 = "PySide2" QT_API_PYQTv2 = "PyQt4v2" @@ -30,12 +34,17 @@ QT_API_ENV = QT_API_ENV.lower() # Mapping of QT_API_ENV to requested binding. ETS does not support PyQt4v1. # (https://github.com/enthought/pyface/blob/master/pyface/qt/__init__.py) -_ETS = {"pyqt5": QT_API_PYQT5, "pyside2": QT_API_PYSIDE2, - None: None} -# First, check if anything is already imported. Use ``sys.modules.get(name)`` -# rather than ``name in sys.modules`` as entries can also have been explicitly -# set to None. -if sys.modules.get("PyQt5.QtCore"): +_ETS = { + "pyqt6": QT_API_PYQT6, "pyside6": QT_API_PYSIDE6, + "pyqt5": QT_API_PYQT5, "pyside2": QT_API_PYSIDE2, + None: None +} +# First, check if anything is already imported. +if sys.modules.get("PyQt6.QtCore"): + QT_API = QT_API_PYQT6 +elif sys.modules.get("PySide6.QtCore"): + QT_API = QT_API_PYSIDE6 +elif sys.modules.get("PyQt5.QtCore"): QT_API = QT_API_PYQT5 elif sys.modules.get("PySide2.QtCore"): QT_API = QT_API_PYSIDE2 @@ -51,20 +60,34 @@ QT_API = None # A non-Qt backend was selected but we still got there (possible, e.g., when # fully manually embedding Matplotlib in a Qt app without using pyplot). +elif QT_API_ENV is None: + QT_API = None else: try: QT_API = _ETS[QT_API_ENV] except KeyError as err: raise RuntimeError( "The environment variable QT_API has the unrecognized value {!r};" - "valid values are 'pyqt5', and 'pyside2'") from err + "valid values are {}".format( + QT_API, ", ".join(map(repr, _ETS)))) from None -def _setup_pyqt5(): +def _setup_pyqt5plus(): global QtCore, QtGui, QtWidgets, __version__, is_pyqt5, \ _isdeleted, _getSaveFileName - if QT_API == QT_API_PYQT5: + if QT_API == QT_API_PYQT6: + from PyQt6 import QtCore, QtGui, QtWidgets, sip + __version__ = QtCore.PYQT_VERSION_STR + QtCore.Signal = QtCore.pyqtSignal + QtCore.Slot = QtCore.pyqtSlot + QtCore.Property = QtCore.pyqtProperty + _isdeleted = sip.isdeleted + elif QT_API == QT_API_PYSIDE6: + from PySide6 import QtCore, QtGui, QtWidgets, __version__ + import shiboken6 + def _isdeleted(obj): return not shiboken6.isValid(obj) + elif QT_API == QT_API_PYQT5: from PyQt5 import QtCore, QtGui, QtWidgets import sip __version__ = QtCore.PYQT_VERSION_STR @@ -77,16 +100,18 @@ def _setup_pyqt5(): import shiboken2 def _isdeleted(obj): return not shiboken2.isValid(obj) else: - raise ValueError("Unexpected value for the 'backend.qt5' rcparam") + raise AssertionError(f"Unexpected QT_API: {QT_API}") _getSaveFileName = QtWidgets.QFileDialog.getSaveFileName -if QT_API in [QT_API_PYQT5, QT_API_PYSIDE2]: - _setup_pyqt5() +if QT_API in [QT_API_PYQT6, QT_API_PYQT5, QT_API_PYSIDE6, QT_API_PYSIDE2]: + _setup_pyqt5plus() elif QT_API is None: # See above re: dict.__getitem__. _candidates = [ - (_setup_pyqt5, QT_API_PYQT5), - (_setup_pyqt5, QT_API_PYSIDE2), + (_setup_pyqt5plus, QT_API_PYQT6), + (_setup_pyqt5plus, QT_API_PYSIDE6), + (_setup_pyqt5plus, QT_API_PYQT5), + (_setup_pyqt5plus, QT_API_PYSIDE2), ] for _setup, QT_API in _candidates: try: @@ -97,7 +122,7 @@ def _isdeleted(obj): return not shiboken2.isValid(obj) else: raise ImportError("Failed to import any qt binding") else: # We should not get there. - raise AssertionError("Unexpected QT_API: {}".format(QT_API)) + raise AssertionError(f"Unexpected QT_API: {QT_API}") # Fixes issues with Big Sur @@ -115,6 +140,28 @@ def _isdeleted(obj): return not shiboken2.isValid(obj) QT_RC_MAJOR_VERSION = int(QtCore.qVersion().split(".")[0]) +# PyQt6 enum compat helpers. + + +_to_int = operator.attrgetter("value") if QT_API == "PyQt6" else int + + +@functools.lru_cache(None) +def _enum(name): + # foo.bar.Enum.Entry (PyQt6) <=> foo.bar.Entry (non-PyQt6). + return operator.attrgetter( + name if QT_API == "PyQt6" else name.rpartition(".")[0] + )(sys.modules[QtCore.__package__]) + + +# Backports. + + +def _exec(obj): + # exec on PyQt6, exec_ elsewhere. + obj.exec() if hasattr(obj, "exec") else obj.exec_() + + def _devicePixelRatioF(obj): """ Return obj.devicePixelRatioF() with graceful fallback for older Qt. diff --git a/lib/matplotlib/backends/qt_editor/_formlayout.py b/lib/matplotlib/backends/qt_editor/_formlayout.py index 4ccc368c25f4..952089a68d31 100644 --- a/lib/matplotlib/backends/qt_editor/_formlayout.py +++ b/lib/matplotlib/backends/qt_editor/_formlayout.py @@ -47,7 +47,8 @@ from numbers import Integral, Real from matplotlib import _api, colors as mcolors -from matplotlib.backends.qt_compat import QtGui, QtWidgets, QtCore +from .. import qt_compat +from ..qt_compat import QtGui, QtWidgets, QtCore, _enum _log = logging.getLogger(__name__) @@ -203,8 +204,7 @@ def get_font(self): def is_edit_valid(edit): text = edit.text() state = edit.validator().validate(text, 0)[0] - - return state == QtGui.QDoubleValidator.Acceptable + return state == _enum("QtGui.QDoubleValidator.State").Acceptable class FormWidget(QtWidgets.QWidget): @@ -291,10 +291,7 @@ def setup(self): field.setCurrentIndex(selindex) elif isinstance(value, bool): field = QtWidgets.QCheckBox(self) - if value: - field.setCheckState(QtCore.Qt.Checked) - else: - field.setCheckState(QtCore.Qt.Unchecked) + field.setChecked(value) elif isinstance(value, Integral): field = QtWidgets.QSpinBox(self) field.setRange(-10**9, 10**9) @@ -336,7 +333,7 @@ def get(self): else: value = value[index] elif isinstance(value, bool): - value = field.checkState() == QtCore.Qt.Checked + value = field.isChecked() elif isinstance(value, Integral): value = int(field.value()) elif isinstance(value, Real): @@ -444,10 +441,12 @@ def __init__(self, data, title="", comment="", # Button box self.bbox = bbox = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) + _enum("QtWidgets.QDialogButtonBox.StandardButtons").Ok + | _enum("QtWidgets.QDialogButtonBox.StandardButtons").Cancel) self.formwidget.update_buttons.connect(self.update_buttons) if self.apply_callback is not None: - apply_btn = bbox.addButton(QtWidgets.QDialogButtonBox.Apply) + apply_btn = bbox.addButton( + _enum("QtWidgets.QDialogButtonBox.StandardButtons").Apply) apply_btn.clicked.connect(self.apply) bbox.accepted.connect(self.accept) @@ -470,9 +469,10 @@ def update_buttons(self): for field in self.float_fields: if not is_edit_valid(field): valid = False - for btn_type in (QtWidgets.QDialogButtonBox.Ok, - QtWidgets.QDialogButtonBox.Apply): - btn = self.bbox.button(btn_type) + for btn_type in ["Ok", "Cancel"]: + btn = self.bbox.button( + getattr(_enum("QtWidgets.QDialogButtonBox.StandardButtons"), + btn_type)) if btn is not None: btn.setEnabled(valid) @@ -533,7 +533,8 @@ def fedit(data, title="", comment="", icon=None, parent=None, apply=None): parent._fedit_dialog.close() parent._fedit_dialog = dialog - dialog.show() + if qt_compat._exec(dialog): + return dialog.get() if __name__ == "__main__": diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index 95ef41d97978..349e24e5d42e 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -136,13 +136,14 @@ def test_correct_key(backend, qt_core, qt_key, qt_mods, answer): Catch the event. Assert sent and caught keys are the same. """ - qt_mod = qt_core.Qt.NoModifier + from matplotlib.backends.qt_compat import _enum, _to_int + qt_mod = _enum("QtCore.Qt.KeyboardModifiers").NoModifier for mod in qt_mods: - qt_mod |= getattr(qt_core.Qt, mod) + qt_mod |= getattr(_enum("QtCore.Qt.KeyboardModifiers"), mod) class _Event: def isAutoRepeat(self): return False - def key(self): return getattr(qt_core.Qt, qt_key) + def key(self): return _to_int(getattr(_enum("QtCore.Qt.Key"), qt_key)) def modifiers(self): return qt_mod def on_key_press(event): @@ -229,9 +230,7 @@ def set_device_pixel_ratio(ratio): @pytest.mark.backend('Qt5Agg', skip_on_importerror=True) def test_subplottool(): fig, ax = plt.subplots() - with mock.patch( - "matplotlib.backends.backend_qt5.SubplotToolQt.exec_", - lambda self: None): + with mock.patch("matplotlib.backends.qt_compat._exec", lambda obj: None): fig.canvas.manager.toolbar.configure_subplots() @@ -241,9 +240,7 @@ def test_figureoptions(): ax.plot([1, 2]) ax.imshow([[1]]) ax.scatter(range(3), range(3), c=range(3)) - with mock.patch( - "matplotlib.backends.qt_editor._formlayout.FormDialog.exec_", - lambda self: None): + with mock.patch("matplotlib.backends.qt_compat._exec", lambda obj: None): fig.canvas.manager.toolbar.edit_parameters() From 197d94405d6cc218d6002cfb5d3550e2136544f0 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Mon, 18 Jan 2021 20:54:58 +0100 Subject: [PATCH 02/11] Single qt backend. Testing all qt bindings actually caught the fact that PySide makes the backend not thread-safe. Co-authored-by: Elliott Sales de Andrade --- .github/workflows/tests.yml | 8 + doc/devel/dependencies.rst | 4 +- doc/users/interactive.rst | 4 +- .../user_interfaces/embedding_in_qt_sgskip.py | 20 +- lib/matplotlib/__init__.py | 5 +- lib/matplotlib/backend_bases.py | 18 +- lib/matplotlib/backends/backend_qt.py | 1020 ++++++++++++++++ lib/matplotlib/backends/backend_qt5.py | 1027 +---------------- lib/matplotlib/backends/backend_qt5agg.py | 92 +- lib/matplotlib/backends/backend_qt5cairo.py | 52 +- lib/matplotlib/backends/backend_qtagg.py | 90 ++ lib/matplotlib/backends/backend_qtcairo.py | 50 + .../backends/qt_editor/_formlayout.py | 2 +- lib/matplotlib/cbook/__init__.py | 12 +- lib/matplotlib/mpl-data/matplotlibrc | 6 +- lib/matplotlib/pyplot.py | 4 +- lib/matplotlib/rcsetup.py | 16 +- lib/matplotlib/tests/test_backend_qt.py | 30 +- .../tests/test_backends_interactive.py | 72 +- setup.cfg.template | 2 +- tutorials/introductory/images.py | 2 +- tutorials/introductory/usage.py | 41 +- 22 files changed, 1318 insertions(+), 1259 deletions(-) create mode 100644 lib/matplotlib/backends/backend_qt.py create mode 100644 lib/matplotlib/backends/backend_qtagg.py create mode 100644 lib/matplotlib/backends/backend_qtcairo.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 28398cd1973e..a225ea52971e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -179,6 +179,14 @@ jobs: python -c 'import PySide2.QtCore' && echo 'PySide2 is available' || echo 'PySide2 is not available' + python -mpip install --upgrade pyqt6 && + python -c 'import PyQt6.QtCore' && + echo 'PyQt6 is available' || + echo 'PyQt6 is not available' + python -mpip install --upgrade pyside6 && + python -c 'import PySide6.QtCore' && + echo 'PySide6 is available' || + echo 'PySide6 is not available' fi python -m pip install --upgrade \ -f https://extras.wxpython.org/wxPython4/extras/linux/gtk3/ubuntu-$(lsb_release -r -s) \ diff --git a/doc/devel/dependencies.rst b/doc/devel/dependencies.rst index 32e0211e6ec6..74682223b0f5 100644 --- a/doc/devel/dependencies.rst +++ b/doc/devel/dependencies.rst @@ -41,7 +41,7 @@ Matplotlib figures can be rendered to various user interfaces. See and the capabilities they provide. * Tk_ (>= 8.3, != 8.6.0 or 8.6.1) [#]_: for the Tk-based backends. -* PyQt5_ or PySide2_: for the Qt5-based backends. +* PyQt6_, PySide6_, PyQt5_, or PySide2_: for the Qt-based backends. * PyGObject_: for the GTK3-based backends [#]_. * wxPython_ (>= 4) [#]_: for the wx-based backends. * pycairo_ (>= 1.11.0) or cairocffi_ (>= 0.8): for the GTK3 and/or cairo-based @@ -51,6 +51,8 @@ and the capabilities they provide. .. _Tk: https://docs.python.org/3/library/tk.html .. _PyQt5: https://pypi.org/project/PyQt5/ .. _PySide2: https://pypi.org/project/PySide2/ +.. _PyQt6: https://pypi.org/project/PyQt6/ +.. _PySide6: https://pypi.org/project/PySide6/ .. _PyGObject: https://pygobject.readthedocs.io/en/latest/ .. _wxPython: https://www.wxpython.org/ .. _pycairo: https://pycairo.readthedocs.io/en/latest/ diff --git a/doc/users/interactive.rst b/doc/users/interactive.rst index c6760594a8a9..bec400761c36 100644 --- a/doc/users/interactive.rst +++ b/doc/users/interactive.rst @@ -63,7 +63,7 @@ it also ensures that the GUI toolkit event loop is properly integrated with the command line (see :ref:`cp_integration`). In this example, we create and modify a figure via an IPython prompt. -The figure displays in a Qt5Agg GUI window. To configure the integration +The figure displays in a QtAgg GUI window. To configure the integration and enable :ref:`interactive mode ` use the ``%matplotlib`` magic: @@ -72,7 +72,7 @@ and enable :ref:`interactive mode ` use the :: In [1]: %matplotlib - Using matplotlib backend: Qt5Agg + Using matplotlib backend: QtAgg In [2]: import matplotlib.pyplot as plt diff --git a/examples/user_interfaces/embedding_in_qt_sgskip.py b/examples/user_interfaces/embedding_in_qt_sgskip.py index 7e367ad60842..f276a2a47c16 100644 --- a/examples/user_interfaces/embedding_in_qt_sgskip.py +++ b/examples/user_interfaces/embedding_in_qt_sgskip.py @@ -4,9 +4,9 @@ =============== Simple Qt application embedding Matplotlib canvases. This program will work -equally well using Qt4 and Qt5. Either version of Qt can be selected (for -example) by setting the ``MPLBACKEND`` environment variable to "Qt4Agg" or -"Qt5Agg", or by first importing the desired version of PyQt. +equally well using any Qt binding (PyQt6, PySide6, PyQt5, PySide2). The +binding can be selected by setting the ``QT_API`` environment variable to the +binding name, or by first importing it. """ import sys @@ -14,8 +14,8 @@ import numpy as np -from matplotlib.backends.qt_compat import QtCore, QtWidgets -from matplotlib.backends.backend_qt5agg import ( +from matplotlib.backends.qt_compat import QtWidgets +from matplotlib.backends.backend_qtagg import ( FigureCanvas, NavigationToolbar2QT as NavigationToolbar) from matplotlib.figure import Figure @@ -28,13 +28,15 @@ def __init__(self): layout = QtWidgets.QVBoxLayout(self._main) static_canvas = FigureCanvas(Figure(figsize=(5, 3))) + # Ideally one would use self.addToolBar here, but it is slightly + # incompatible between PyQt6 and other bindings, so we just add the + # toolbar as a plain widget instead. + layout.addWidget(NavigationToolbar(static_canvas, self)) layout.addWidget(static_canvas) - self.addToolBar(NavigationToolbar(static_canvas, self)) dynamic_canvas = FigureCanvas(Figure(figsize=(5, 3))) layout.addWidget(dynamic_canvas) - self.addToolBar(QtCore.Qt.BottomToolBarArea, - NavigationToolbar(dynamic_canvas, self)) + layout.addWidget(NavigationToolbar(dynamic_canvas, self)) self._static_ax = static_canvas.figure.subplots() t = np.linspace(0, 10, 501) @@ -66,4 +68,4 @@ def _update_canvas(self): app.show() app.activateWindow() app.raise_() - qapp.exec_() + qapp.exec() diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index a62ab3e5cc46..9f72eea1944a 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -1100,9 +1100,8 @@ def use(backend, *, force=True): backend names, which are case-insensitive: - interactive backends: - GTK3Agg, GTK3Cairo, MacOSX, nbAgg, - Qt5Agg, Qt5Cairo, - TkAgg, TkCairo, WebAgg, WX, WXAgg, WXCairo + GTK3Agg, GTK3Cairo, MacOSX, nbAgg, QtAgg, QtCairo, + TkAgg, TkCairo, WebAgg, WX, WXAgg, WXCairo, Qt5Agg, Qt5Cairo - non-interactive backends: agg, cairo, pdf, pgf, ps, svg, template diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 5be359b9c426..d7be356d66d2 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -98,12 +98,14 @@ def _safe_pyplot_import(): current_framework = cbook._get_running_interactive_framework() if current_framework is None: raise # No, something else went wrong, likely with the install... - backend_mapping = {'qt5': 'qt5agg', - 'gtk3': 'gtk3agg', - 'wx': 'wxagg', - 'tk': 'tkagg', - 'macosx': 'macosx', - 'headless': 'agg'} + backend_mapping = { + 'qt': 'qtagg', + 'gtk3': 'gtk3agg', + 'wx': 'wxagg', + 'tk': 'tkagg', + 'macosx': 'macosx', + 'headless': 'agg', + } backend = backend_mapping[current_framework] rcParams["backend"] = mpl.rcParamsOrig["backend"] = backend import matplotlib.pyplot as plt # Now this should succeed. @@ -1662,7 +1664,7 @@ class FigureCanvasBase: A high-level figure instance. """ - # Set to one of {"qt5", "gtk3", "wx", "tk", "macosx"} if an + # Set to one of {"qt", "gtk3", "wx", "tk", "macosx"} if an # interactive framework is required, or None otherwise. required_interactive_framework = None @@ -1738,7 +1740,7 @@ def _fix_ipython_backend2gui(cls): # don't break on our side. return rif = getattr(cls, "required_interactive_framework", None) - backend2gui_rif = {"qt5": "qt", "gtk3": "gtk3", + backend2gui_rif = {"qt": "qt", "gtk3": "gtk3", "wx": "wx", "macosx": "osx"}.get(rif) if backend2gui_rif: if _is_non_interactive_terminal_ipython(ip): diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py new file mode 100644 index 000000000000..4c5a1966e392 --- /dev/null +++ b/lib/matplotlib/backends/backend_qt.py @@ -0,0 +1,1020 @@ +import functools +import operator +import os +import signal +import sys +import traceback + +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, cursors, ToolContainerBase, MouseButton) +import matplotlib.backends.qt_editor.figureoptions as figureoptions +from . import qt_compat +from .qt_compat import ( + QtCore, QtGui, QtWidgets, __version__, QT_API, + _enum, _to_int, + _devicePixelRatioF, _isdeleted, _setDevicePixelRatio, +) + +backend_version = __version__ + +# SPECIAL_KEYS are Qt::Key that do *not* return their unicode name +# instead they have manually specified names. +SPECIAL_KEYS = { + _to_int(getattr(_enum("QtCore.Qt.Key"), k)): v for k, v in [ + ("Key_Escape", "escape"), + ("Key_Tab", "tab"), + ("Key_Backspace", "backspace"), + ("Key_Return", "enter"), + ("Key_Enter", "enter"), + ("Key_Insert", "insert"), + ("Key_Delete", "delete"), + ("Key_Pause", "pause"), + ("Key_SysReq", "sysreq"), + ("Key_Clear", "clear"), + ("Key_Home", "home"), + ("Key_End", "end"), + ("Key_Left", "left"), + ("Key_Up", "up"), + ("Key_Right", "right"), + ("Key_Down", "down"), + ("Key_PageUp", "pageup"), + ("Key_PageDown", "pagedown"), + ("Key_Shift", "shift"), + # In OSX, the control and super (aka cmd/apple) keys are switched. + ("Key_Control", "control" if sys.platform != "darwin" else "cmd"), + ("Key_Meta", "meta" if sys.platform != "darwin" else "control"), + ("Key_Alt", "alt"), + ("Key_CapsLock", "caps_lock"), + ("Key_F1", "f1"), + ("Key_F2", "f2"), + ("Key_F3", "f3"), + ("Key_F4", "f4"), + ("Key_F5", "f5"), + ("Key_F6", "f6"), + ("Key_F7", "f7"), + ("Key_F8", "f8"), + ("Key_F9", "f9"), + ("Key_F10", "f10"), + ("Key_F10", "f11"), + ("Key_F12", "f12"), + ("Key_Super_L", "super"), + ("Key_Super_R", "super"), + ] +} +# Define which modifier keys are collected on keyboard events. +# Elements are (Qt::KeyboardModifiers, Qt::Key) tuples. +# Order determines the modifier order (ctrl+alt+...) reported by Matplotlib. +_MODIFIER_KEYS = [ + (_to_int(getattr(_enum("QtCore.Qt.KeyboardModifiers"), mod)), + _to_int(getattr(_enum("QtCore.Qt.Key"), key))) + for mod, key in [ + ("ControlModifier", "Key_Control"), + ("AltModifier", "Key_Alt"), + ("ShiftModifier", "Key_Shift"), + ("MetaModifier", "Key_Meta"), + ] +] +cursord = { + k: getattr(_enum("QtCore.Qt.CursorShape"), v) for k, v in [ + (cursors.MOVE, "SizeAllCursor"), + (cursors.HAND, "PointingHandCursor"), + (cursors.POINTER, "ArrowCursor"), + (cursors.SELECT_REGION, "CrossCursor"), + (cursors.WAIT, "WaitCursor"), + (cursors.RESIZE_HORIZONTAL, "SizeHorCursor"), + (cursors.RESIZE_VERTICAL, "SizeVerCursor"), + ] +} +SUPER = 0 # Deprecated. +ALT = 1 # Deprecated. +CTRL = 2 # Deprecated. +SHIFT = 3 # Deprecated. +MODIFIER_KEYS = [ # Deprecated. + (SPECIAL_KEYS[key], mod, key) for mod, key in _MODIFIER_KEYS] + + +# 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: + # display_is_valid returns False only if on Linux and neither X11 + # nor Wayland display can be opened. + if not mpl._c_internal_utils.display_is_valid(): + raise RuntimeError('Invalid DISPLAY variable') + try: + QtWidgets.QApplication.setAttribute( + QtCore.Qt.AA_EnableHighDpiScaling) + except AttributeError: # Only for Qt>=5.6, <6. + pass + try: + QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy( + QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough) + except AttributeError: # Only for Qt>=5.14. + pass + qApp = QtWidgets.QApplication(["matplotlib"]) + qApp.lastWindowClosed.connect(qApp.quit) + cbook._setup_new_guiapp() + else: + qApp = app + + try: + qApp.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps) # Only for Qt<6. + except AttributeError: + pass + + +def _allow_super_init(__init__): + """ + Decorator for ``__init__`` to allow ``super().__init__`` on PySide2. + """ + + if QT_API in ["PyQt5", "PyQt6"]: + + return __init__ + + else: + # To work around lack of cooperative inheritance in PySide2 and + # PySide6, 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 or sip, or PySide{,2,6} + # 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 [ + "PySide2", "PySide6", "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 `.TimerBase` using QTimer events.""" + + def __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) + super().__init__(*args, **kwargs) + + def __del__(self): + # The check for deletedness is needed to avoid an error at animation + # shutdown with PySide2. + if not _isdeleted(self._timer): + self._timer_stop() + + 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): + required_interactive_framework = "qt" + _timer_cls = TimerQT + + buttond = { + getattr(_enum("QtCore.Qt.MouseButtons"), k): v for k, v in [ + ("LeftButton", MouseButton.LEFT), + ("RightButton", MouseButton.RIGHT), + ("MiddleButton", MouseButton.MIDDLE), + ("XButton1", MouseButton.BACK), + ("XButton2", MouseButton.FORWARD), + ] + } + + @_allow_super_init + def __init__(self, figure=None): + _create_qApp() + super().__init__(figure=figure) + + self._draw_pending = False + self._is_drawing = False + self._draw_rect_callback = lambda painter: None + + self.setAttribute( + _enum("QtCore.Qt.WidgetAttribute").WA_OpaquePaintEvent) + self.setMouseTracking(True) + self.resize(*self.get_width_height()) + + palette = QtGui.QPalette(QtGui.QColor("white")) + self.setPalette(palette) + + def _update_pixel_ratio(self): + if self._set_device_pixel_ratio(_devicePixelRatioF(self)): + # 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) + + def _update_screen(self, screen): + # Handler for changes to a window's attached screen. + self._update_pixel_ratio() + if screen is not None: + screen.physicalDotsPerInchChanged.connect(self._update_pixel_ratio) + screen.logicalDotsPerInchChanged.connect(self._update_pixel_ratio) + + def showEvent(self, event): + # Set up correct pixel ratio, and connect to any signal changes for it, + # once the window is shown (and thus has these attributes). + window = self.window().windowHandle() + window.screenChanged.connect(self._update_screen) + self._update_screen(window.screen()) + + def set_cursor(self, cursor): + # docstring inherited + self.setCursor(_api.check_getitem(cursord, cursor=cursor)) + + def enterEvent(self, event): + x, y = self.mouseEventCoords(self._get_position(event)) + FigureCanvasBase.enter_notify_event(self, guiEvent=event, xy=(x, y)) + + def leaveEvent(self, event): + QtWidgets.QApplication.restoreOverrideCursor() + FigureCanvasBase.leave_notify_event(self, guiEvent=event) + + _get_position = operator.methodcaller( + "position" if QT_API in ["PyQt6", "PySide6"] else "pos") + + def mouseEventCoords(self, pos): + """ + Calculate mouse coordinates in physical pixels. + + Qt uses 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 = pos.x() + # flip y so y=0 is bottom of canvas + y = self.figure.bbox.height / self.device_pixel_ratio - pos.y() + return x * self.device_pixel_ratio, y * self.device_pixel_ratio + + def mousePressEvent(self, event): + x, y = self.mouseEventCoords(self._get_position(event)) + 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(self._get_position(event)) + 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(self._get_position(event)) + FigureCanvasBase.motion_notify_event(self, x, y, guiEvent=event) + + def mouseReleaseEvent(self, event): + x, y = self.mouseEventCoords(self._get_position(event)) + button = self.buttond.get(event.button()) + if button is not None: + FigureCanvasBase.button_release_event(self, x, y, button, + guiEvent=event) + + def wheelEvent(self, event): + x, y = self.mouseEventCoords(self._get_position(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) + + 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) + + def resizeEvent(self, event): + frame = sys._getframe() + if frame.f_code is frame.f_back.f_code: # Prevent PyQt6 recursion. + return + w = event.size().width() * self.device_pixel_ratio + h = event.size().height() * self.device_pixel_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): + event_key = event.key() + event_mods = _to_int(event.modifiers()) # actually a bitmask + + # get names of the pressed modifier keys + # 'control' is named 'control' when a standalone key, but 'ctrl' when a + # modifier + # 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 = [SPECIAL_KEYS[key].replace('control', 'ctrl') + for mod, key in _MODIFIER_KEYS + if event_key != key and event_mods & mod] + 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 0x10ffff (sys.maxunicode) + # 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 + if event_key > sys.maxunicode: + 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() + + return '+'.join(mods + [key]) + + 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 > 0: + timer = QtCore.QTimer.singleShot(int(timeout * 1000), + event_loop.quit) + qt_compat._exec(event_loop) + + 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 blit(self, bbox=None): + # docstring inherited + if bbox is None and self.figure: + bbox = self.figure.bbox # Blit the entire canvas if bbox is None. + # repaint uses logical pixels, not physical pixels like the renderer. + l, b, w, h = [int(pt / self.device_pixel_ratio) for pt in bbox.bounds] + t = b + h + self.repaint(l, self.rect().height() - t, w, h) + + def _draw_idle(self): + with self._idle_draw_cntx(): + if not self._draw_pending: + return + self._draw_pending = False + if self.height() < 0 or self.width() < 0: + return + try: + self.draw() + except Exception: + # Uncaught exceptions are fatal for PyQt5, so catch them. + traceback.print_exc() + + 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: + x0, y0, w, h = [int(pt / self.device_pixel_ratio) for pt in rect] + x1 = x0 + w + y1 = y0 + h + def _draw_rect_callback(painter): + pen = QtGui.QPen( + QtGui.QColor("black"), + 1 / self.device_pixel_ratio + ) + + pen.setDashPattern([3, 3]) + for color, offset in [ + (QtGui.QColor("black"), 0), + (QtGui.QColor("white"), 3), + ]: + pen.setDashOffset(offset) + pen.setColor(color) + painter.setPen(pen) + # Draw the lines from x0, y0 towards x1, y1 so that the + # dashes don't "jump" when moving the zoom box. + painter.drawLine(x0, y0, x0, y1) + painter.drawLine(x0, y0, x1, y0) + painter.drawLine(x0, y1, x1, y1) + painter.drawLine(x1, y0, x1, y1) + 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() + super().closeEvent(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): + self.window = MainWindow() + super().__init__(canvas, num) + self.window.closing.connect(canvas.close_event) + self.window.closing.connect(self._widgetclosed) + + image = str(cbook._get_data_path('images/matplotlib.svg')) + self.window.setWindowIcon(QtGui.QIcon(image)) + + self.window._destroying = False + + self.toolbar = self._get_toolbar(self.canvas, self.window) + + 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: + self.window.addToolBar(self.toolbar) + 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() + cs_height = cs.height() + height = cs_height + tbs_height + self.window.resize(cs.width(), height) + + self.window.setCentralWidget(self.canvas) + + if mpl.is_interactive(): + self.window.show() + self.canvas.draw_idle() + + # 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 without clicking. + # https://doc.qt.io/qt-5/qt.html#FocusPolicy-enum + self.canvas.setFocusPolicy(_enum("QtCore.Qt.FocusPolicy").StrongFocus) + self.canvas.setFocus() + + 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) + 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 mpl.rcParams['toolbar'] == 'toolbar2': + toolbar = NavigationToolbar2QT(canvas, parent, True) + elif mpl.rcParams['toolbar'] == 'toolmanager': + toolbar = ToolbarQt(self.toolmanager, self.window) + else: + toolbar = None + return toolbar + + 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.canvas.resize(width, height) + self.window.resize(width + extra_width, height + extra_height) + + def show(self): + self.window.show() + if mpl.rcParams['figure.raise_window']: + 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) + + toolitems = [*NavigationToolbar2.toolitems] + toolitems.insert( + # Add 'customize' action after 'subplots' + [name for name, *_ in toolitems].index("Subplots") + 1, + ("Customize", "Edit axis, curve and image parameters", + "qt4_editor_options", "edit_parameters")) + + def __init__(self, canvas, parent, coordinates=True): + """coordinates: should we show the coordinates on the right?""" + QtWidgets.QToolBar.__init__(self, parent) + self.setAllowedAreas( + _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea + | _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea) + + self.coordinates = coordinates + self._actions = {} # mapping of toolitem method names to QActions. + self._subplot_dialog = None + + 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) + + # 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( + _enum("QtCore.Qt.Alignment").AlignRight + | _enum("QtCore.Qt.Alignment").AlignVCenter) + self.locLabel.setSizePolicy(QtWidgets.QSizePolicy( + _enum("QtWidgets.QSizePolicy.Policy").Expanding, + _enum("QtWidgets.QSizePolicy.Policy").Ignored, + )) + labelAction = self.addWidget(self.locLabel) + labelAction.setVisible(True) + + NavigationToolbar2.__init__(self, canvas) + + def _icon(self, name): + """ + Construct a `.QIcon` from an image file *name*, including the extension + and relative to Matplotlib's "images" data directory. + """ + name = name.replace('.png', '_large.png') + pm = QtGui.QPixmap(str(cbook._get_data_path('images', name))) + _setDevicePixelRatio(pm, _devicePixelRatioF(self)) + if self.palette().color(self.backgroundRole()).value() < 128: + icon_color = self.palette().color(self.foregroundRole()) + mask = pm.createMaskFromColor( + QtGui.QColor('black'), + _enum("QtCore.Qt.MaskMode").MaskOutColor) + pm.fill(icon_color) + pm.setMask(mask) + return QtGui.QIcon(pm) + + def edit_parameters(self): + axes = self.canvas.figure.get_axes() + if not axes: + QtWidgets.QMessageBox.warning( + self.canvas.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 + ax.get_title("left") or + ax.get_title("right") or + " - ".join(filter(None, [ax.get_xlabel(), ax.get_ylabel()])) or + f"" + 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.canvas.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 + if 'pan' in self._actions: + self._actions['pan'].setChecked(self.mode.name == 'PAN') + if 'zoom' in self._actions: + self._actions['zoom'].setChecked(self.mode.name == '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 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 = str(cbook._get_data_path('images/matplotlib.png')) + self._subplot_dialog = SubplotToolQt( + self.canvas.figure, self.canvas.parent()) + self._subplot_dialog.setWindowIcon(QtGui.QIcon(image)) + self._subplot_dialog.show() + + 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(mpl.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 = qt_compat._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 != "": + mpl.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(QtWidgets.QDialog): + def __init__(self, targetfig, parent): + super().__init__() + self.setObjectName("SubplotTool") + self._spinboxes = {} + main_layout = QtWidgets.QHBoxLayout() + self.setLayout(main_layout) + for group, spinboxes, buttons in [ + ("Borders", + ["top", "bottom", "left", "right"], + [("Export values", self._export_values)]), + ("Spacings", + ["hspace", "wspace"], + [("Tight layout", self._tight_layout), + ("Reset", self._reset), + ("Close", self.close)])]: + layout = QtWidgets.QVBoxLayout() + main_layout.addLayout(layout) + box = QtWidgets.QGroupBox(group) + layout.addWidget(box) + inner = QtWidgets.QFormLayout(box) + for name in spinboxes: + self._spinboxes[name] = spinbox = QtWidgets.QDoubleSpinBox() + spinbox.setValue(getattr(targetfig.subplotpars, name)) + spinbox.setRange(0, 1) + spinbox.setDecimals(3) + spinbox.setSingleStep(0.005) + spinbox.setKeyboardTracking(False) + spinbox.valueChanged.connect(self._on_value_changed) + inner.addRow(name, spinbox) + layout.addStretch(1) + for name, method in buttons: + button = QtWidgets.QPushButton(name) + # Don't trigger on , which is used to input values. + button.setAutoDefault(False) + button.clicked.connect(method) + layout.addWidget(button) + if name == "Close": + button.setFocus() + self._figure = targetfig + self._defaults = {spinbox: vars(self._figure.subplotpars)[attr] + for attr, spinbox in self._spinboxes.items()} + self._export_values_dialog = None + + def _export_values(self): + # Explicitly round to 3 decimals (which is also the spinbox precision) + # to avoid numbers of the form 0.100...001. + self._export_values_dialog = QtWidgets.QDialog() + layout = QtWidgets.QVBoxLayout() + self._export_values_dialog.setLayout(layout) + text = QtWidgets.QPlainTextEdit() + text.setReadOnly(True) + layout.addWidget(text) + text.setPlainText( + ",\n".join(f"{attr}={spinbox.value():.3}" + for attr, spinbox in self._spinboxes.items())) + # 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) + self._export_values_dialog.show() + + def _on_value_changed(self): + spinboxes = self._spinboxes + # Set all mins and maxes, so that this can also be used in _reset(). + for lower, higher in [("bottom", "top"), ("left", "right")]: + spinboxes[higher].setMinimum(spinboxes[lower].value() + .001) + spinboxes[lower].setMaximum(spinboxes[higher].value() - .001) + self._figure.subplots_adjust( + **{attr: spinbox.value() for attr, spinbox in spinboxes.items()}) + self._figure.canvas.draw_idle() + + def _tight_layout(self): + self._figure.tight_layout() + for attr, spinbox in self._spinboxes.items(): + spinbox.blockSignals(True) + spinbox.setValue(vars(self._figure.subplotpars)[attr]) + spinbox.blockSignals(False) + self._figure.canvas.draw_idle() + + def _reset(self): + for spinbox, value in self._defaults.items(): + spinbox.setRange(0, 1) + spinbox.blockSignals(True) + spinbox.setValue(value) + spinbox.blockSignals(False) + self._on_value_changed() + + +class ToolbarQt(ToolContainerBase, QtWidgets.QToolBar): + def __init__(self, toolmanager, parent): + ToolContainerBase.__init__(self, toolmanager) + QtWidgets.QToolBar.__init__(self, parent) + self.setAllowedAreas( + _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea + | _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea) + message_label = QtWidgets.QLabel("") + message_label.setAlignment( + _enum("QtCore.Qt.Alignment").AlignRight + | _enum("QtCore.Qt.Alignment").AlignVCenter) + message_label.setSizePolicy(QtWidgets.QSizePolicy( + _enum("QtWidgets.QSizePolicy.Policy").Expanding, + _enum("QtWidgets.QSizePolicy.Policy").Ignored, + )) + self._message_action = self.addWidget(message_label) + self._toolitems = {} + self._groups = {} + + def add_toolitem( + self, name, group, position, image_file, description, toggle): + + button = QtWidgets.QToolButton(self) + if image_file: + button.setIcon(NavigationToolbar2QT._icon(self, 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.insertSeparator(self._message_action) + gr.append(sep) + before = gr[position] + widget = self.insertWidget(before, button) + gr.insert(position, widget) + self._groups[group] = gr + + 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] + + def set_message(self, s): + self.widgetForAction(self._message_action).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()) + + +@_api.deprecated("3.5", alternative="ToolSetCursor") +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.ToolRubberband = RubberbandQt +backend_tools.ToolHelp = HelpQt +backend_tools.ToolCopyToClipboard = ToolCopyToClipboardQT + + +@_Backend.export +class _BackendQT(_Backend): + FigureCanvas = FigureCanvasQT + FigureManager = FigureManagerQT + + @staticmethod + def mainloop(): + old_signal = signal.getsignal(signal.SIGINT) + # allow SIGINT exceptions to close the plot window. + is_python_signal_handler = old_signal is not None + if is_python_signal_handler: + signal.signal(signal.SIGINT, signal.SIG_DFL) + try: + qt_compat._exec(qApp) + finally: + # reset the SIGINT exception handler + if is_python_signal_handler: + signal.signal(signal.SIGINT, old_signal) diff --git a/lib/matplotlib/backends/backend_qt5.py b/lib/matplotlib/backends/backend_qt5.py index 484a2b049f16..fa7e8a7d3671 100644 --- a/lib/matplotlib/backends/backend_qt5.py +++ b/lib/matplotlib/backends/backend_qt5.py @@ -1,1020 +1,13 @@ -import functools -import operator -import os -import signal -import sys -import traceback - -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, cursors, ToolContainerBase, MouseButton) -import matplotlib.backends.qt_editor.figureoptions as figureoptions -from . import qt_compat -from .qt_compat import ( - QtCore, QtGui, QtWidgets, __version__, QT_API, - _enum, _to_int, - _devicePixelRatioF, _isdeleted, _setDevicePixelRatio, +from .backend_qt import ( + backend_version, SPECIAL_KEYS, + SUPER, ALT, CTRL, SHIFT, MODIFIER_KEYS, # These are deprecated. + cursord, _create_qApp, _BackendQT, TimerQT, MainWindow, FigureCanvasQT, + FigureManagerQT, NavigationToolbar2QT, SubplotToolQt, + SaveFigureQt, ConfigureSubplotsQt, SetCursorQt, RubberbandQt, + HelpQt, ToolCopyToClipboardQt ) -backend_version = __version__ - -# SPECIAL_KEYS are Qt::Key that do *not* return their unicode name -# instead they have manually specified names. -SPECIAL_KEYS = { - _to_int(getattr(_enum("QtCore.Qt.Key"), k)): v for k, v in [ - ("Key_Escape", "escape"), - ("Key_Tab", "tab"), - ("Key_Backspace", "backspace"), - ("Key_Return", "enter"), - ("Key_Enter", "enter"), - ("Key_Insert", "insert"), - ("Key_Delete", "delete"), - ("Key_Pause", "pause"), - ("Key_SysReq", "sysreq"), - ("Key_Clear", "clear"), - ("Key_Home", "home"), - ("Key_End", "end"), - ("Key_Left", "left"), - ("Key_Up", "up"), - ("Key_Right", "right"), - ("Key_Down", "down"), - ("Key_PageUp", "pageup"), - ("Key_PageDown", "pagedown"), - ("Key_Shift", "shift"), - # In OSX, the control and super (aka cmd/apple) keys are switched. - ("Key_Control", "control" if sys.platform != "darwin" else "cmd"), - ("Key_Meta", "meta" if sys.platform != "darwin" else "control"), - ("Key_Alt", "alt"), - ("Key_CapsLock", "caps_lock"), - ("Key_F1", "f1"), - ("Key_F2", "f2"), - ("Key_F3", "f3"), - ("Key_F4", "f4"), - ("Key_F5", "f5"), - ("Key_F6", "f6"), - ("Key_F7", "f7"), - ("Key_F8", "f8"), - ("Key_F9", "f9"), - ("Key_F10", "f10"), - ("Key_F10", "f11"), - ("Key_F12", "f12"), - ("Key_Super_L", "super"), - ("Key_Super_R", "super"), - ] -} -# Define which modifier keys are collected on keyboard events. -# Elements are (Qt::KeyboardModifiers, Qt::Key) tuples. -# Order determines the modifier order (ctrl+alt+...) reported by Matplotlib. -_MODIFIER_KEYS = [ - (_to_int(getattr(_enum("QtCore.Qt.KeyboardModifiers"), mod)), - _to_int(getattr(_enum("QtCore.Qt.Key"), key))) - for mod, key in [ - ("ControlModifier", "Key_Control"), - ("AltModifier", "Key_Alt"), - ("ShiftModifier", "Key_Shift"), - ("MetaModifier", "Key_Meta"), - ] -] -cursord = { - k: getattr(_enum("QtCore.Qt.CursorShape"), v) for k, v in [ - (cursors.MOVE, "SizeAllCursor"), - (cursors.HAND, "PointingHandCursor"), - (cursors.POINTER, "ArrowCursor"), - (cursors.SELECT_REGION, "CrossCursor"), - (cursors.WAIT, "WaitCursor"), - (cursors.RESIZE_HORIZONTAL, "SizeHorCursor"), - (cursors.RESIZE_VERTICAL, "SizeVerCursor"), - ] -} -SUPER = 0 # Deprecated. -ALT = 1 # Deprecated. -CTRL = 2 # Deprecated. -SHIFT = 3 # Deprecated. -MODIFIER_KEYS = [ # Deprecated. - (SPECIAL_KEYS[key], mod, key) for mod, key in _MODIFIER_KEYS] - - -# 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: - # display_is_valid returns False only if on Linux and neither X11 - # nor Wayland display can be opened. - if not mpl._c_internal_utils.display_is_valid(): - raise RuntimeError('Invalid DISPLAY variable') - try: - QtWidgets.QApplication.setAttribute( - QtCore.Qt.AA_EnableHighDpiScaling) - except AttributeError: # Only for Qt>=5.6, <6. - pass - try: - QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy( - QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough) - except AttributeError: # Only for Qt>=5.14. - pass - qApp = QtWidgets.QApplication(["matplotlib"]) - qApp.lastWindowClosed.connect(qApp.quit) - cbook._setup_new_guiapp() - else: - qApp = app - - try: - qApp.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps) # Only for Qt<6. - except AttributeError: - pass - - -def _allow_super_init(__init__): - """ - Decorator for ``__init__`` to allow ``super().__init__`` on PySide2. - """ - - if QT_API in ["PyQt5", "PyQt6"]: - - return __init__ - - else: - # To work around lack of cooperative inheritance in PySide2 and - # PySide6, 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 or sip, or PySide{,2,6} - # 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 [ - "PySide2", "PySide6", "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 `.TimerBase` using QTimer events.""" - - def __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) - super().__init__(*args, **kwargs) - - def __del__(self): - # The check for deletedness is needed to avoid an error at animation - # shutdown with PySide2. - if not _isdeleted(self._timer): - self._timer_stop() - - 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): - required_interactive_framework = "qt5" - _timer_cls = TimerQT - - buttond = { - getattr(_enum("QtCore.Qt.MouseButtons"), k): v for k, v in [ - ("LeftButton", MouseButton.LEFT), - ("RightButton", MouseButton.RIGHT), - ("MiddleButton", MouseButton.MIDDLE), - ("XButton1", MouseButton.BACK), - ("XButton2", MouseButton.FORWARD), - ] - } - - @_allow_super_init - def __init__(self, figure=None): - _create_qApp() - super().__init__(figure=figure) - - self._draw_pending = False - self._is_drawing = False - self._draw_rect_callback = lambda painter: None - - self.setAttribute( - _enum("QtCore.Qt.WidgetAttribute").WA_OpaquePaintEvent) - self.setMouseTracking(True) - self.resize(*self.get_width_height()) - - palette = QtGui.QPalette(QtGui.QColor("white")) - self.setPalette(palette) - - def _update_pixel_ratio(self): - if self._set_device_pixel_ratio(_devicePixelRatioF(self)): - # 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) - - def _update_screen(self, screen): - # Handler for changes to a window's attached screen. - self._update_pixel_ratio() - if screen is not None: - screen.physicalDotsPerInchChanged.connect(self._update_pixel_ratio) - screen.logicalDotsPerInchChanged.connect(self._update_pixel_ratio) - - def showEvent(self, event): - # Set up correct pixel ratio, and connect to any signal changes for it, - # once the window is shown (and thus has these attributes). - window = self.window().windowHandle() - window.screenChanged.connect(self._update_screen) - self._update_screen(window.screen()) - - def set_cursor(self, cursor): - # docstring inherited - self.setCursor(_api.check_getitem(cursord, cursor=cursor)) - - def enterEvent(self, event): - x, y = self.mouseEventCoords(self._get_position(event)) - FigureCanvasBase.enter_notify_event(self, guiEvent=event, xy=(x, y)) - - def leaveEvent(self, event): - QtWidgets.QApplication.restoreOverrideCursor() - FigureCanvasBase.leave_notify_event(self, guiEvent=event) - - _get_position = operator.methodcaller( - "position" if QT_API in ["PyQt6", "PySide6"] else "pos") - - 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. - """ - x = pos.x() - # flip y so y=0 is bottom of canvas - y = self.figure.bbox.height / self.device_pixel_ratio - pos.y() - return x * self.device_pixel_ratio, y * self.device_pixel_ratio - - def mousePressEvent(self, event): - x, y = self.mouseEventCoords(self._get_position(event)) - 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(self._get_position(event)) - 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(self._get_position(event)) - FigureCanvasBase.motion_notify_event(self, x, y, guiEvent=event) - - def mouseReleaseEvent(self, event): - x, y = self.mouseEventCoords(self._get_position(event)) - button = self.buttond.get(event.button()) - if button is not None: - FigureCanvasBase.button_release_event(self, x, y, button, - guiEvent=event) - - def wheelEvent(self, event): - x, y = self.mouseEventCoords(self._get_position(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) - - 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) - - def resizeEvent(self, event): - frame = sys._getframe() - if frame.f_code is frame.f_back.f_code: # Prevent PyQt6 recursion. - return - w = event.size().width() * self.device_pixel_ratio - h = event.size().height() * self.device_pixel_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): - event_key = event.key() - event_mods = _to_int(event.modifiers()) # actually a bitmask - - # get names of the pressed modifier keys - # 'control' is named 'control' when a standalone key, but 'ctrl' when a - # modifier - # 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 = [SPECIAL_KEYS[key].replace('control', 'ctrl') - for mod, key in _MODIFIER_KEYS - if event_key != key and event_mods & mod] - 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 0x10ffff (sys.maxunicode) - # 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 - if event_key > sys.maxunicode: - 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() - - return '+'.join(mods + [key]) - - 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 > 0: - timer = QtCore.QTimer.singleShot(int(timeout * 1000), - event_loop.quit) - qt_compat._exec(event_loop) - - 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 blit(self, bbox=None): - # docstring inherited - if bbox is None and self.figure: - bbox = self.figure.bbox # Blit the entire canvas if bbox is None. - # repaint uses logical pixels, not physical pixels like the renderer. - l, b, w, h = [int(pt / self.device_pixel_ratio) for pt in bbox.bounds] - t = b + h - self.repaint(l, self.rect().height() - t, w, h) - - def _draw_idle(self): - with self._idle_draw_cntx(): - if not self._draw_pending: - return - self._draw_pending = False - if self.height() < 0 or self.width() < 0: - return - try: - self.draw() - except Exception: - # Uncaught exceptions are fatal for PyQt5, so catch them. - traceback.print_exc() - - 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: - x0, y0, w, h = [int(pt / self.device_pixel_ratio) for pt in rect] - x1 = x0 + w - y1 = y0 + h - def _draw_rect_callback(painter): - pen = QtGui.QPen( - QtGui.QColor("black"), - 1 / self.device_pixel_ratio - ) - - pen.setDashPattern([3, 3]) - for color, offset in [ - (QtGui.QColor("black"), 0), - (QtGui.QColor("white"), 3), - ]: - pen.setDashOffset(offset) - pen.setColor(color) - painter.setPen(pen) - # Draw the lines from x0, y0 towards x1, y1 so that the - # dashes don't "jump" when moving the zoom box. - painter.drawLine(x0, y0, x0, y1) - painter.drawLine(x0, y0, x1, y0) - painter.drawLine(x0, y1, x1, y1) - painter.drawLine(x1, y0, x1, y1) - 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() - super().closeEvent(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): - self.window = MainWindow() - super().__init__(canvas, num) - self.window.closing.connect(canvas.close_event) - self.window.closing.connect(self._widgetclosed) - - image = str(cbook._get_data_path('images/matplotlib.svg')) - self.window.setWindowIcon(QtGui.QIcon(image)) - - self.window._destroying = False - - self.toolbar = self._get_toolbar(self.canvas, self.window) - - 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: - self.window.addToolBar(self.toolbar) - 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() - cs_height = cs.height() - height = cs_height + tbs_height - self.window.resize(cs.width(), height) - - self.window.setCentralWidget(self.canvas) - - if mpl.is_interactive(): - self.window.show() - self.canvas.draw_idle() - - # 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 without clicking. - # https://doc.qt.io/qt-5/qt.html#FocusPolicy-enum - self.canvas.setFocusPolicy(_enum("QtCore.Qt.FocusPolicy").StrongFocus) - self.canvas.setFocus() - - 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) - 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 mpl.rcParams['toolbar'] == 'toolbar2': - toolbar = NavigationToolbar2QT(canvas, parent, True) - elif mpl.rcParams['toolbar'] == 'toolmanager': - toolbar = ToolbarQt(self.toolmanager, self.window) - else: - toolbar = None - return toolbar - - 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.canvas.resize(width, height) - self.window.resize(width + extra_width, height + extra_height) - - def show(self): - self.window.show() - if mpl.rcParams['figure.raise_window']: - 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) - - toolitems = [*NavigationToolbar2.toolitems] - toolitems.insert( - # Add 'customize' action after 'subplots' - [name for name, *_ in toolitems].index("Subplots") + 1, - ("Customize", "Edit axis, curve and image parameters", - "qt4_editor_options", "edit_parameters")) - - def __init__(self, canvas, parent, coordinates=True): - """coordinates: should we show the coordinates on the right?""" - QtWidgets.QToolBar.__init__(self, parent) - self.setAllowedAreas( - _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea - | _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea) - - self.coordinates = coordinates - self._actions = {} # mapping of toolitem method names to QActions. - self._subplot_dialog = None - - 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) - - # 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( - _enum("QtCore.Qt.Alignment").AlignRight - | _enum("QtCore.Qt.Alignment").AlignVCenter) - self.locLabel.setSizePolicy(QtWidgets.QSizePolicy( - _enum("QtWidgets.QSizePolicy.Policy").Expanding, - _enum("QtWidgets.QSizePolicy.Policy").Ignored, - )) - labelAction = self.addWidget(self.locLabel) - labelAction.setVisible(True) - - NavigationToolbar2.__init__(self, canvas) - - def _icon(self, name): - """ - Construct a `.QIcon` from an image file *name*, including the extension - and relative to Matplotlib's "images" data directory. - """ - name = name.replace('.png', '_large.png') - pm = QtGui.QPixmap(str(cbook._get_data_path('images', name))) - _setDevicePixelRatio(pm, _devicePixelRatioF(self)) - if self.palette().color(self.backgroundRole()).value() < 128: - icon_color = self.palette().color(self.foregroundRole()) - mask = pm.createMaskFromColor( - QtGui.QColor('black'), - _enum("QtCore.Qt.MaskMode").MaskOutColor) - pm.fill(icon_color) - pm.setMask(mask) - return QtGui.QIcon(pm) - - def edit_parameters(self): - axes = self.canvas.figure.get_axes() - if not axes: - QtWidgets.QMessageBox.warning( - self.canvas.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 - ax.get_title("left") or - ax.get_title("right") or - " - ".join(filter(None, [ax.get_xlabel(), ax.get_ylabel()])) or - f"" - 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.canvas.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 - if 'pan' in self._actions: - self._actions['pan'].setChecked(self.mode.name == 'PAN') - if 'zoom' in self._actions: - self._actions['zoom'].setChecked(self.mode.name == '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 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 = str(cbook._get_data_path('images/matplotlib.png')) - self._subplot_dialog = SubplotToolQt( - self.canvas.figure, self.canvas.parent()) - self._subplot_dialog.setWindowIcon(QtGui.QIcon(image)) - self._subplot_dialog.show() - - 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(mpl.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 = qt_compat._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 != "": - mpl.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(QtWidgets.QDialog): - def __init__(self, targetfig, parent): - super().__init__() - self.setObjectName("SubplotTool") - self._spinboxes = {} - main_layout = QtWidgets.QHBoxLayout() - self.setLayout(main_layout) - for group, spinboxes, buttons in [ - ("Borders", - ["top", "bottom", "left", "right"], - [("Export values", self._export_values)]), - ("Spacings", - ["hspace", "wspace"], - [("Tight layout", self._tight_layout), - ("Reset", self._reset), - ("Close", self.close)])]: - layout = QtWidgets.QVBoxLayout() - main_layout.addLayout(layout) - box = QtWidgets.QGroupBox(group) - layout.addWidget(box) - inner = QtWidgets.QFormLayout(box) - for name in spinboxes: - self._spinboxes[name] = spinbox = QtWidgets.QDoubleSpinBox() - spinbox.setValue(getattr(targetfig.subplotpars, name)) - spinbox.setRange(0, 1) - spinbox.setDecimals(3) - spinbox.setSingleStep(0.005) - spinbox.setKeyboardTracking(False) - spinbox.valueChanged.connect(self._on_value_changed) - inner.addRow(name, spinbox) - layout.addStretch(1) - for name, method in buttons: - button = QtWidgets.QPushButton(name) - # Don't trigger on , which is used to input values. - button.setAutoDefault(False) - button.clicked.connect(method) - layout.addWidget(button) - if name == "Close": - button.setFocus() - self._figure = targetfig - self._defaults = {spinbox: vars(self._figure.subplotpars)[attr] - for attr, spinbox in self._spinboxes.items()} - self._export_values_dialog = None - - def _export_values(self): - # Explicitly round to 3 decimals (which is also the spinbox precision) - # to avoid numbers of the form 0.100...001. - self._export_values_dialog = QtWidgets.QDialog() - layout = QtWidgets.QVBoxLayout() - self._export_values_dialog.setLayout(layout) - text = QtWidgets.QPlainTextEdit() - text.setReadOnly(True) - layout.addWidget(text) - text.setPlainText( - ",\n".join(f"{attr}={spinbox.value():.3}" - for attr, spinbox in self._spinboxes.items())) - # 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) - self._export_values_dialog.show() - - def _on_value_changed(self): - spinboxes = self._spinboxes - # Set all mins and maxes, so that this can also be used in _reset(). - for lower, higher in [("bottom", "top"), ("left", "right")]: - spinboxes[higher].setMinimum(spinboxes[lower].value() + .001) - spinboxes[lower].setMaximum(spinboxes[higher].value() - .001) - self._figure.subplots_adjust( - **{attr: spinbox.value() for attr, spinbox in spinboxes.items()}) - self._figure.canvas.draw_idle() - - def _tight_layout(self): - self._figure.tight_layout() - for attr, spinbox in self._spinboxes.items(): - spinbox.blockSignals(True) - spinbox.setValue(vars(self._figure.subplotpars)[attr]) - spinbox.blockSignals(False) - self._figure.canvas.draw_idle() - - def _reset(self): - for spinbox, value in self._defaults.items(): - spinbox.setRange(0, 1) - spinbox.blockSignals(True) - spinbox.setValue(value) - spinbox.blockSignals(False) - self._on_value_changed() - - -class ToolbarQt(ToolContainerBase, QtWidgets.QToolBar): - def __init__(self, toolmanager, parent): - ToolContainerBase.__init__(self, toolmanager) - QtWidgets.QToolBar.__init__(self, parent) - self.setAllowedAreas( - _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea - | _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea) - message_label = QtWidgets.QLabel("") - message_label.setAlignment( - _enum("QtCore.Qt.Alignment").AlignRight - | _enum("QtCore.Qt.Alignment").AlignVCenter) - message_label.setSizePolicy(QtWidgets.QSizePolicy( - _enum("QtWidgets.QSizePolicy.Policy").Expanding, - _enum("QtWidgets.QSizePolicy.Policy").Ignored, - )) - self._message_action = self.addWidget(message_label) - self._toolitems = {} - self._groups = {} - - def add_toolitem( - self, name, group, position, image_file, description, toggle): - - button = QtWidgets.QToolButton(self) - if image_file: - button.setIcon(NavigationToolbar2QT._icon(self, 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.insertSeparator(self._message_action) - gr.append(sep) - before = gr[position] - widget = self.insertWidget(before, button) - gr.insert(position, widget) - self._groups[group] = gr - - 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] - - def set_message(self, s): - self.widgetForAction(self._message_action).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()) - - -@_api.deprecated("3.5", alternative="ToolSetCursor") -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.ToolRubberband = RubberbandQt -backend_tools.ToolHelp = HelpQt -backend_tools.ToolCopyToClipboard = ToolCopyToClipboardQT - - -@_Backend.export -class _BackendQT5(_Backend): - FigureCanvas = FigureCanvasQT - FigureManager = FigureManagerQT - @staticmethod - def mainloop(): - old_signal = signal.getsignal(signal.SIGINT) - # allow SIGINT exceptions to close the plot window. - is_python_signal_handler = old_signal is not None - if is_python_signal_handler: - signal.signal(signal.SIGINT, signal.SIG_DFL) - try: - qt_compat._exec(qApp) - finally: - # reset the SIGINT exception handler - if is_python_signal_handler: - signal.signal(signal.SIGINT, old_signal) +@_BackendQT.export +class _BackendQT5(_BackendQT): + pass diff --git a/lib/matplotlib/backends/backend_qt5agg.py b/lib/matplotlib/backends/backend_qt5agg.py index a900fd16b1a3..faf3dabaef8c 100644 --- a/lib/matplotlib/backends/backend_qt5agg.py +++ b/lib/matplotlib/backends/backend_qt5agg.py @@ -1,90 +1,12 @@ """ -Render to qt from agg. +Render to qt from agg """ -import ctypes +from .backend_qtagg import ( + _BackendQTAgg, FigureCanvasQTAgg, FigureManagerQT, NavigationToolbar2QT, + backend_version) -from matplotlib.transforms import Bbox -from .. import cbook -from .backend_agg import FigureCanvasAgg -from .backend_qt5 import ( - QtCore, QtGui, QtWidgets, _BackendQT5, FigureCanvasQT, FigureManagerQT, - NavigationToolbar2QT, backend_version) -from .qt_compat import QT_API, _enum, _setDevicePixelRatio - - -class FigureCanvasQTAgg(FigureCanvasAgg, FigureCanvasQT): - - def __init__(self, figure=None): - # Must pass 'figure' as kwarg to Qt base class. - super().__init__(figure=figure) - - def paintEvent(self, event): - """ - Copy the image from the Agg canvas to the qt.drawable. - - In Qt, all drawing should be done inside of here when a widget is - shown onscreen. - """ - self._draw_idle() # Only does something if a draw is pending. - - # If the canvas does not have a renderer, then give up and wait for - # FigureCanvasAgg.draw(self) to be called. - if not hasattr(self, 'renderer'): - return - - painter = QtGui.QPainter(self) - try: - # See documentation of QRect: bottom() and right() are off - # by 1, so use left() + width() and top() + height(). - rect = event.rect() - # scale rect dimensions using the screen dpi ratio to get - # correct values for the Figure coordinates (rather than - # QT5's coords) - width = rect.width() * self.device_pixel_ratio - height = rect.height() * self.device_pixel_ratio - left, top = self.mouseEventCoords(rect.topLeft()) - # shift the "top" by the height of the image to get the - # correct corner for our coordinate system - bottom = top - height - # same with the right side of the image - right = left + width - # create a buffer using the image bounding box - bbox = Bbox([[left, bottom], [right, top]]) - reg = self.copy_from_bbox(bbox) - buf = cbook._unmultiplied_rgba8888_to_premultiplied_argb32( - memoryview(reg)) - - # clear the widget canvas - painter.eraseRect(rect) - - if QT_API == "PyQt6": - from PyQt6 import sip - ptr = sip.voidptr(buf) - else: - ptr = buf - qimage = QtGui.QImage( - ptr, buf.shape[1], buf.shape[0], - _enum("QtGui.QImage.Format").Format_ARGB32_Premultiplied) - _setDevicePixelRatio(qimage, self.device_pixel_ratio) - # set origin using original QT coordinates - origin = QtCore.QPoint(rect.left(), rect.top()) - painter.drawImage(origin, qimage) - # Adjust the buf reference count to work around a memory - # leak bug in QImage under PySide. - if QT_API in ('PySide', 'PySide2'): - ctypes.c_long.from_address(id(buf)).value = 1 - - self._draw_rect_callback(painter) - finally: - painter.end() - - def print_figure(self, *args, **kwargs): - super().print_figure(*args, **kwargs) - self.draw() - - -@_BackendQT5.export -class _BackendQT5Agg(_BackendQT5): - FigureCanvas = FigureCanvasQTAgg +@_BackendQTAgg.export +class _BackendQT5Agg(_BackendQTAgg): + pass diff --git a/lib/matplotlib/backends/backend_qt5cairo.py b/lib/matplotlib/backends/backend_qt5cairo.py index 010c6aabb9b0..5f8c20202868 100644 --- a/lib/matplotlib/backends/backend_qt5cairo.py +++ b/lib/matplotlib/backends/backend_qt5cairo.py @@ -1,50 +1,6 @@ -import ctypes +from .backend_qtcairo import _BackendQTCairo, FigureCanvasQTCairo -from .backend_cairo import cairo, FigureCanvasCairo, RendererCairo -from .backend_qt5 import QtCore, QtGui, _BackendQT5, FigureCanvasQT -from .qt_compat import QT_API, _enum, _setDevicePixelRatio - -class FigureCanvasQTCairo(FigureCanvasQT, FigureCanvasCairo): - def __init__(self, figure=None): - super().__init__(figure=figure) - self._renderer = RendererCairo(self.figure.dpi) - self._renderer.set_width_height(-1, -1) # Invalid values. - - def draw(self): - if hasattr(self._renderer.gc, "ctx"): - self.figure.draw(self._renderer) - super().draw() - - def paintEvent(self, event): - width = int(self.device_pixel_ratio * self.width()) - height = int(self.device_pixel_ratio * self.height()) - if (width, height) != self._renderer.get_canvas_width_height(): - surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) - self._renderer.set_ctx_from_surface(surface) - self._renderer.set_width_height(width, height) - self.figure.draw(self._renderer) - buf = self._renderer.gc.ctx.get_target().get_data() - if QT_API == "PyQt6": - import sip - ptr = sip.voidptr(buf) - else: - ptr = buf - qimage = QtGui.QImage( - ptr, width, height, - _enum("QtGui.QImage.Format").Format_ARGB32_Premultiplied) - # Adjust the buf reference count to work around a memory leak bug in - # QImage under PySide. - if QT_API in ('PySide', 'PySide2'): - ctypes.c_long.from_address(id(buf)).value = 1 - _setDevicePixelRatio(qimage, self.device_pixel_ratio) - painter = QtGui.QPainter(self) - painter.eraseRect(event.rect()) - painter.drawImage(0, 0, qimage) - self._draw_rect_callback(painter) - painter.end() - - -@_BackendQT5.export -class _BackendQT5Cairo(_BackendQT5): - FigureCanvas = FigureCanvasQTCairo +@_BackendQTCairo.export +class _BackendQT5Cairo(_BackendQTCairo): + pass diff --git a/lib/matplotlib/backends/backend_qtagg.py b/lib/matplotlib/backends/backend_qtagg.py new file mode 100644 index 000000000000..3d96fbda8da5 --- /dev/null +++ b/lib/matplotlib/backends/backend_qtagg.py @@ -0,0 +1,90 @@ +""" +Render to qt from agg. +""" + +import ctypes + +from matplotlib.transforms import Bbox + +from .qt_compat import QT_API, _enum, _setDevicePixelRatio +from .. import cbook +from .backend_agg import FigureCanvasAgg +from .backend_qt import ( + QtCore, QtGui, QtWidgets, _BackendQT, FigureCanvasQT, FigureManagerQT, + NavigationToolbar2QT, backend_version) + + +class FigureCanvasQTAgg(FigureCanvasAgg, FigureCanvasQT): + + def __init__(self, figure=None): + # Must pass 'figure' as kwarg to Qt base class. + super().__init__(figure=figure) + + def paintEvent(self, event): + """ + Copy the image from the Agg canvas to the qt.drawable. + + In Qt, all drawing should be done inside of here when a widget is + shown onscreen. + """ + self._draw_idle() # Only does something if a draw is pending. + + # If the canvas does not have a renderer, then give up and wait for + # FigureCanvasAgg.draw(self) to be called. + if not hasattr(self, 'renderer'): + return + + painter = QtGui.QPainter(self) + try: + # See documentation of QRect: bottom() and right() are off + # by 1, so use left() + width() and top() + height(). + rect = event.rect() + # scale rect dimensions using the screen dpi ratio to get + # correct values for the Figure coordinates (rather than + # QT5's coords) + width = rect.width() * self.device_pixel_ratio + height = rect.height() * self.device_pixel_ratio + left, top = self.mouseEventCoords(rect.topLeft()) + # shift the "top" by the height of the image to get the + # correct corner for our coordinate system + bottom = top - height + # same with the right side of the image + right = left + width + # create a buffer using the image bounding box + bbox = Bbox([[left, bottom], [right, top]]) + reg = self.copy_from_bbox(bbox) + buf = cbook._unmultiplied_rgba8888_to_premultiplied_argb32( + memoryview(reg)) + + # clear the widget canvas + painter.eraseRect(rect) + + if QT_API == "PyQt6": + from PyQt6 import sip + ptr = sip.voidptr(buf) + else: + ptr = buf + qimage = QtGui.QImage( + ptr, buf.shape[1], buf.shape[0], + _enum("QtGui.QImage.Format").Format_ARGB32_Premultiplied) + _setDevicePixelRatio(qimage, self.device_pixel_ratio) + # set origin using original QT coordinates + origin = QtCore.QPoint(rect.left(), rect.top()) + painter.drawImage(origin, qimage) + # Adjust the buf reference count to work around a memory + # leak bug in QImage under PySide. + if QT_API in ('PySide', 'PySide2'): + ctypes.c_long.from_address(id(buf)).value = 1 + + self._draw_rect_callback(painter) + finally: + painter.end() + + def print_figure(self, *args, **kwargs): + super().print_figure(*args, **kwargs) + self.draw() + + +@_BackendQT.export +class _BackendQTAgg(_BackendQT): + FigureCanvas = FigureCanvasQTAgg diff --git a/lib/matplotlib/backends/backend_qtcairo.py b/lib/matplotlib/backends/backend_qtcairo.py new file mode 100644 index 000000000000..6d0a90b985b4 --- /dev/null +++ b/lib/matplotlib/backends/backend_qtcairo.py @@ -0,0 +1,50 @@ +import ctypes + +from .backend_cairo import cairo, FigureCanvasCairo, RendererCairo +from .backend_qt import QtCore, QtGui, _BackendQT, FigureCanvasQT +from .qt_compat import QT_API, _enum, _setDevicePixelRatio + + +class FigureCanvasQTCairo(FigureCanvasQT, FigureCanvasCairo): + def __init__(self, figure=None): + super().__init__(figure=figure) + self._renderer = RendererCairo(self.figure.dpi) + self._renderer.set_width_height(-1, -1) # Invalid values. + + def draw(self): + if hasattr(self._renderer.gc, "ctx"): + self.figure.draw(self._renderer) + super().draw() + + def paintEvent(self, event): + width = int(self.device_pixel_ratio * self.width()) + height = int(self.device_pixel_ratio * self.height()) + if (width, height) != self._renderer.get_canvas_width_height(): + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + self._renderer.set_ctx_from_surface(surface) + self._renderer.set_width_height(width, height) + self.figure.draw(self._renderer) + buf = self._renderer.gc.ctx.get_target().get_data() + if QT_API == "PyQt6": + from PyQt6 import sip + ptr = sip.voidptr(buf) + else: + ptr = buf + qimage = QtGui.QImage( + ptr, width, height, + _enum("QtGui.QImage.Format").Format_ARGB32_Premultiplied) + # Adjust the buf reference count to work around a memory leak bug in + # QImage under PySide. + if QT_API in ('PySide', 'PySide2'): + ctypes.c_long.from_address(id(buf)).value = 1 + _setDevicePixelRatio(qimage, self.device_pixel_ratio) + painter = QtGui.QPainter(self) + painter.eraseRect(event.rect()) + painter.drawImage(0, 0, qimage) + self._draw_rect_callback(painter) + painter.end() + + +@_BackendQT.export +class _BackendQTCairo(_BackendQT): + FigureCanvas = FigureCanvasQTCairo diff --git a/lib/matplotlib/backends/qt_editor/_formlayout.py b/lib/matplotlib/backends/qt_editor/_formlayout.py index 952089a68d31..cb3a7560bbec 100644 --- a/lib/matplotlib/backends/qt_editor/_formlayout.py +++ b/lib/matplotlib/backends/qt_editor/_formlayout.py @@ -469,7 +469,7 @@ def update_buttons(self): for field in self.float_fields: if not is_edit_valid(field): valid = False - for btn_type in ["Ok", "Cancel"]: + for btn_type in ["Ok", "Apply"]: btn = self.bbox.button( getattr(_enum("QtWidgets.QDialogButtonBox.StandardButtons"), btn_type)) diff --git a/lib/matplotlib/cbook/__init__.py b/lib/matplotlib/cbook/__init__.py index 49f5428928f6..ae7e5cd056e0 100644 --- a/lib/matplotlib/cbook/__init__.py +++ b/lib/matplotlib/cbook/__init__.py @@ -50,15 +50,19 @@ def _get_running_interactive_framework(): Returns ------- Optional[str] - One of the following values: "qt5", "gtk3", "wx", "tk", "macosx", + One of the following values: "qt", "gtk3", "wx", "tk", "macosx", "headless", ``None``. """ # Use ``sys.modules.get(name)`` rather than ``name in sys.modules`` as # entries can also have been explicitly set to None. - QtWidgets = (sys.modules.get("PyQt5.QtWidgets") - or sys.modules.get("PySide2.QtWidgets")) + QtWidgets = ( + sys.modules.get("PyQt6.QtWidgets") + or sys.modules.get("PySide6.QtWidgets") + or sys.modules.get("PyQt5.QtWidgets") + or sys.modules.get("PySide2.QtWidgets") + ) if QtWidgets and QtWidgets.QApplication.instance(): - return "qt5" + return "qt" Gtk = sys.modules.get("gi.repository.Gtk") if Gtk and Gtk.main_level(): return "gtk3" diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index a5540619c9eb..19e89e3cdd5e 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -71,10 +71,10 @@ ## *************************************************************************** ## The default backend. If you omit this parameter, the first working ## backend from the following list is used: -## MacOSX Qt5Agg Gtk3Agg TkAgg WxAgg Agg +## MacOSX QtAgg Gtk3Agg TkAgg WxAgg Agg ## Other choices include: -## Qt5Cairo GTK3Cairo TkCairo WxCairo Cairo -## Wx # deprecated. +## QtCairo GTK3Cairo TkCairo WxCairo Cairo +## Qt5Agg Qt5Cairo Wx # deprecated. ## PS PDF SVG Template ## You can also deploy your own backend outside of Matplotlib by referring to ## the module name (which must be in the PYTHONPATH) as 'module://my_backend'. diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 006bfb31784d..28b14e0129f2 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -210,7 +210,7 @@ def switch_backend(newbackend): if newbackend is rcsetup._auto_backend_sentinel: current_framework = cbook._get_running_interactive_framework() - mapping = {'qt5': 'qt5agg', + mapping = {'qt': 'qtagg', 'gtk3': 'gtk3agg', 'wx': 'wxagg', 'tk': 'tkagg', @@ -222,7 +222,7 @@ def switch_backend(newbackend): candidates = [best_guess] else: candidates = [] - candidates += ["macosx", "qt5agg", "gtk3agg", "tkagg", "wxagg"] + candidates += ["macosx", "qtagg", "gtk3agg", "tkagg", "wxagg"] # Don't try to fallback on the cairo-based backends as they each have # an additional dependency (pycairo) over the agg-based backend, and diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 99f6b1364398..2c3c88e2fa66 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -34,13 +34,15 @@ # The capitalized forms are needed for ipython at present; this may # change for later versions. -interactive_bk = ['GTK3Agg', 'GTK3Cairo', - 'MacOSX', - 'nbAgg', - 'Qt5Agg', 'Qt5Cairo', - 'TkAgg', 'TkCairo', - 'WebAgg', - 'WX', 'WXAgg', 'WXCairo'] +interactive_bk = [ + 'GTK3Agg', 'GTK3Cairo', + 'MacOSX', + 'nbAgg', + 'QtAgg', 'QtCairo', 'Qt5Agg', 'Qt5Cairo', + 'TkAgg', 'TkCairo', + 'WebAgg', + 'WX', 'WXAgg', 'WXCairo', +] non_interactive_bk = ['agg', 'cairo', 'pdf', 'pgf', 'ps', 'svg', 'template'] all_backends = interactive_bk + non_interactive_bk diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index 349e24e5d42e..98a3349b587b 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -14,7 +14,7 @@ from matplotlib.backends.qt_compat import QtGui, QtWidgets from matplotlib.backends.qt_editor import _formlayout except ImportError: - pytestmark = pytest.mark.skip('No usable Qt5 bindings') + pytestmark = pytest.mark.skip('No usable Qt bindings') @pytest.fixture @@ -31,6 +31,9 @@ def qt_core(request): pytest.param( 'Qt5Agg', marks=pytest.mark.backend('Qt5Agg', skip_on_importerror=True)), + pytest.param( + 'QtAgg', + marks=pytest.mark.backend('QtAgg', skip_on_importerror=True)), ]) def test_fig_close(backend): # save the state of Gcf.figs @@ -48,7 +51,7 @@ def test_fig_close(backend): assert init_figs == Gcf.figs -@pytest.mark.backend('Qt5Agg', skip_on_importerror=True) +@pytest.mark.backend('QtAgg', skip_on_importerror=True) def test_fig_signals(qt_core): # Create a figure plt.figure() @@ -79,7 +82,7 @@ def CustomHandler(signum, frame): # mainloop() sets SIGINT, starts Qt event loop (which triggers timer and # exits) and then mainloop() resets SIGINT - matplotlib.backends.backend_qt5._BackendQT5.mainloop() + matplotlib.backends.backend_qt._BackendQT.mainloop() # Assert: signal handler during loop execution is signal.SIG_DFL assert event_loop_signal == signal.SIG_DFL @@ -128,6 +131,9 @@ def CustomHandler(signum, frame): pytest.param( 'Qt5Agg', marks=pytest.mark.backend('Qt5Agg', skip_on_importerror=True)), + pytest.param( + 'QtAgg', + marks=pytest.mark.backend('QtAgg', skip_on_importerror=True)), ]) def test_correct_key(backend, qt_core, qt_key, qt_mods, answer): """ @@ -154,14 +160,14 @@ def on_key_press(event): qt_canvas.keyPressEvent(_Event()) -@pytest.mark.backend('Qt5Agg', skip_on_importerror=True) +@pytest.mark.backend('QtAgg', skip_on_importerror=True) def test_device_pixel_ratio_change(): """ Make sure that if the pixel ratio changes, the figure dpi changes but the widget remains the same logical size. """ - prop = 'matplotlib.backends.backend_qt5.FigureCanvasQT.devicePixelRatioF' + prop = 'matplotlib.backends.backend_qt.FigureCanvasQT.devicePixelRatioF' with mock.patch(prop) as p: p.return_value = 3 @@ -227,14 +233,14 @@ def set_device_pixel_ratio(ratio): assert (fig.get_size_inches() == (5, 2)).all() -@pytest.mark.backend('Qt5Agg', skip_on_importerror=True) +@pytest.mark.backend('QtAgg', skip_on_importerror=True) def test_subplottool(): fig, ax = plt.subplots() with mock.patch("matplotlib.backends.qt_compat._exec", lambda obj: None): fig.canvas.manager.toolbar.configure_subplots() -@pytest.mark.backend('Qt5Agg', skip_on_importerror=True) +@pytest.mark.backend('QtAgg', skip_on_importerror=True) def test_figureoptions(): fig, ax = plt.subplots() ax.plot([1, 2]) @@ -244,7 +250,7 @@ def test_figureoptions(): fig.canvas.manager.toolbar.edit_parameters() -@pytest.mark.backend('Qt5Agg', skip_on_importerror=True) +@pytest.mark.backend('QtAgg', skip_on_importerror=True) def test_figureoptions_with_datetime_axes(): fig, ax = plt.subplots() xydata = [ @@ -253,12 +259,12 @@ def test_figureoptions_with_datetime_axes(): ] ax.plot(xydata, xydata) with mock.patch( - "matplotlib.backends.qt_editor._formlayout.FormDialog.exec_", + "matplotlib.backends.qt_editor._formlayout.FormDialog.exec", lambda self: None): fig.canvas.manager.toolbar.edit_parameters() -@pytest.mark.backend('Qt5Agg', skip_on_importerror=True) +@pytest.mark.backend('QtAgg', skip_on_importerror=True) def test_double_resize(): # Check that resizing a figure twice keeps the same window size fig, ax = plt.subplots() @@ -278,9 +284,9 @@ def test_double_resize(): assert window.height() == old_height -@pytest.mark.backend('Qt5Agg', skip_on_importerror=True) +@pytest.mark.backend('QtAgg', skip_on_importerror=True) def test_canvas_reinit(): - from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg + from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg called = False diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index dc72b68468b8..9a84fecf5c21 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -20,24 +20,20 @@ # versions so we don't fail on missing backends. def _get_testable_interactive_backends(): - try: - from matplotlib.backends.qt_compat import QtGui # noqa - have_qt5 = True - except ImportError: - have_qt5 = False - - backends = [] - for deps, backend in [ - (["cairo", "gi"], "gtk3agg"), - (["cairo", "gi"], "gtk3cairo"), - (["PyQt5"], "qt5agg"), - (["PyQt5", "cairocffi"], "qt5cairo"), - (["PySide2"], "qt5agg"), - (["PySide2", "cairocffi"], "qt5cairo"), - (["tkinter"], "tkagg"), - (["wx"], "wx"), - (["wx"], "wxagg"), - (["matplotlib.backends._macosx"], "macosx"), + envs = [] + for deps, env in [ + *[([qt_api], + {"MPLBACKEND": "qtagg", "QT_API": qt_api}) + for qt_api in ["PyQt6", "PySide6", "PyQt5", "PySide2"]], + *[([qt_api, "cairocffi"], + {"MPLBACKEND": "qtcairo", "QT_API": qt_api}) + for qt_api in ["PyQt6", "PySide6", "PyQt5", "PySide2"]], + (["cairo", "gi"], {"MPLBACKEND": "gtk3agg"}), + (["cairo", "gi"], {"MPLBACKEND": "gtk3cairo"}), + (["tkinter"], {"MPLBACKEND": "tkagg"}), + (["wx"], {"MPLBACKEND": "wx"}), + (["wx"], {"MPLBACKEND": "wxagg"}), + (["matplotlib.backends._macosx"], {"MPLBACKEND": "macosx"}), ]: reason = None missing = [dep for dep in deps if not importlib.util.find_spec(dep)] @@ -46,20 +42,17 @@ def _get_testable_interactive_backends(): reason = "$DISPLAY and $WAYLAND_DISPLAY are unset" elif missing: reason = "{} cannot be imported".format(", ".join(missing)) - elif backend == 'macosx' and os.environ.get('TF_BUILD'): + elif env["MPLBACKEND"] == 'macosx' and os.environ.get('TF_BUILD'): reason = "macosx backend fails on Azure" - elif 'qt5' in backend and not have_qt5: - reason = "no usable Qt5 bindings" marks = [] if reason: marks.append(pytest.mark.skip( - reason=f"Skipping {backend} because {reason}")) - elif backend.startswith('wx') and sys.platform == 'darwin': + reason=f"Skipping {env} because {reason}")) + elif env["MPLBACKEND"].startswith('wx') and sys.platform == 'darwin': # ignore on OSX because that's currently broken (github #16849) marks.append(pytest.mark.xfail(reason='github #16849')) - backend = pytest.param(backend, marks=marks) - backends.append(backend) - return backends + envs.append(pytest.param(env, marks=marks, id=str(env))) + return envs _test_timeout = 10 # Empirically, 1s is not enough on CI. @@ -154,11 +147,11 @@ def check_alt_backend(alt_backend): assert_equal(result.getvalue(), result_after.getvalue()) -@pytest.mark.parametrize("backend", _get_testable_interactive_backends()) +@pytest.mark.parametrize("env", _get_testable_interactive_backends()) @pytest.mark.parametrize("toolbar", ["toolbar2", "toolmanager"]) @pytest.mark.flaky(reruns=3) -def test_interactive_backend(backend, toolbar): - if backend == "macosx": +def test_interactive_backend(env, toolbar): + if env["MPLBACKEND"] == "macosx": if toolbar == "toolmanager": pytest.skip("toolmanager is not implemented for macosx.") @@ -167,7 +160,7 @@ def test_interactive_backend(backend, toolbar): inspect.getsource(_test_interactive_impl) + "\n_test_interactive_impl()", json.dumps({"toolbar": toolbar})], - env={**os.environ, "MPLBACKEND": backend, "SOURCE_DATE_EPOCH": "0"}, + env={**os.environ, "SOURCE_DATE_EPOCH": "0", **env}, timeout=_test_timeout, stdout=subprocess.PIPE, universal_newlines=True) if proc.returncode: @@ -212,7 +205,7 @@ def _test_thread_impl(): _thread_safe_backends = _get_testable_interactive_backends() # Known unsafe backends. Remove the xfails if they start to pass! for param in _thread_safe_backends: - backend = param.values[0] + backend = param.values[0]["MPLBACKEND"] if "cairo" in backend: # Cairo backends save a cairo_t on the graphics context, and sharing # these is not threadsafe. @@ -224,15 +217,21 @@ def _test_thread_impl(): elif backend == "macosx": param.marks.append( pytest.mark.xfail(raises=subprocess.TimeoutExpired, strict=True)) + elif param.values[0].get("QT_API") == "PySide2": + param.marks.append( + pytest.mark.xfail(raises=subprocess.CalledProcessError)) + elif param.values[0].get("QT_API") == "PySide6": + param.marks.append( + pytest.mark.xfail(raises=subprocess.TimeoutExpired)) -@pytest.mark.parametrize("backend", _thread_safe_backends) +@pytest.mark.parametrize("env", _thread_safe_backends) @pytest.mark.flaky(reruns=3) -def test_interactive_thread_safety(backend): +def test_interactive_thread_safety(env): proc = subprocess.run( [sys.executable, "-c", inspect.getsource(_test_thread_impl) + "\n_test_thread_impl()"], - env={**os.environ, "MPLBACKEND": backend, "SOURCE_DATE_EPOCH": "0"}, + env={**os.environ, "SOURCE_DATE_EPOCH": "0", **env}, timeout=_test_timeout, check=True, stdout=subprocess.PIPE, universal_newlines=True) assert proc.stdout.count("CloseEvent") == 1 @@ -269,7 +268,7 @@ def test_webagg(): @pytest.mark.skipif(sys.platform != "linux", reason="this a linux-only test") -@pytest.mark.backend('Qt5Agg', skip_on_importerror=True) +@pytest.mark.backend('QtAgg', skip_on_importerror=True) def test_lazy_linux_headless(): test_script = """ import os @@ -297,7 +296,8 @@ def test_lazy_linux_headless(): sys.exit(1) """ - proc = subprocess.run([sys.executable, "-c", test_script]) + proc = subprocess.run([sys.executable, "-c", test_script], + env={"MPLBACKEND": ""}) if proc.returncode: pytest.fail("The subprocess returned with non-zero exit status " f"{proc.returncode}.") diff --git a/setup.cfg.template b/setup.cfg.template index 8768decc67ca..05fbfa89eed4 100644 --- a/setup.cfg.template +++ b/setup.cfg.template @@ -34,7 +34,7 @@ license_files = LICENSE/* # User-configurable options # # Default backend, one of: Agg, Cairo, GTK3Agg, GTK3Cairo, MacOSX, Pdf, Ps, -# Qt5Agg, SVG, TkAgg, WX, WXAgg. +# QtAgg, QtCairo, SVG, TkAgg, WX, WXAgg. # # The Agg, Ps, Pdf and SVG backends do not require external dependencies. Do # not choose MacOSX if you have disabled the relevant extension modules. The diff --git a/tutorials/introductory/images.py b/tutorials/introductory/images.py index 50d4a77d6e53..46a6de70ee69 100644 --- a/tutorials/introductory/images.py +++ b/tutorials/introductory/images.py @@ -33,7 +33,7 @@ notebook. This has important implications for interactivity. For inline plotting, commands in cells below the cell that outputs a plot will not affect the plot. For example, changing the colormap is not possible from cells below the cell that creates a plot. -However, for other backends, such as Qt5, that open a separate window, +However, for other backends, such as Qt, that open a separate window, cells below those that create the plot will change the plot - it is a live object in memory. diff --git a/tutorials/introductory/usage.py b/tutorials/introductory/usage.py index 397d7b7a8879..08b4d6ad00a0 100644 --- a/tutorials/introductory/usage.py +++ b/tutorials/introductory/usage.py @@ -308,7 +308,7 @@ def my_plotter(ax, data1, data2, param_dict): # # #. Setting :rc:`backend` in your :file:`matplotlibrc` file:: # -# backend : qt5agg # use pyqt5 with antigrain (agg) rendering +# backend : qtagg # use pyqt with antigrain (agg) rendering # # See also :doc:`/tutorials/introductory/customizing`. # @@ -319,14 +319,14 @@ def my_plotter(ax, data1, data2, param_dict): # # On Unix:: # -# > export MPLBACKEND=qt5agg +# > export MPLBACKEND=qtagg # > python simple_plot.py # -# > MPLBACKEND=qt5agg python simple_plot.py +# > MPLBACKEND=qtagg python simple_plot.py # # On Windows, only the former is possible:: # -# > set MPLBACKEND=qt5agg +# > set MPLBACKEND=qtagg # > python simple_plot.py # # Setting this environment variable will override the ``backend`` parameter @@ -339,7 +339,7 @@ def my_plotter(ax, data1, data2, param_dict): # :func:`matplotlib.use`:: # # import matplotlib -# matplotlib.use('qt5agg') +# matplotlib.use('qtagg') # # This should be done before any figure is created, otherwise Matplotlib may # fail to switch the backend and raise an ImportError. @@ -364,14 +364,15 @@ def my_plotter(ax, data1, data2, param_dict): # If, however, you want to write graphical user interfaces, or a web # application server # (:doc:`/gallery/user_interfaces/web_application_server_sgskip`), or need a -# better understanding of what is going on, read on. To make things more easily -# customizable for graphical user interfaces, Matplotlib separates the concept -# of the renderer (the thing that actually does the drawing) from the canvas -# (the place where the drawing goes). The canonical renderer for user -# interfaces is ``Agg`` which uses the `Anti-Grain Geometry`_ C++ library to -# make a raster (pixel) image of the figure; it is used by the ``Qt5Agg``, -# ``GTK3Agg``, ``wxAgg``, ``TkAgg``, and ``macosx`` backends. An alternative -# renderer is based on the Cairo library, used by ``Qt5Cairo``, etc. +# better understanding of what is going on, read on. To make things easily +# more customizable for graphical user interfaces, Matplotlib separates +# the concept of the renderer (the thing that actually does the drawing) +# from the canvas (the place where the drawing goes). The canonical +# renderer for user interfaces is ``Agg`` which uses the `Anti-Grain +# Geometry`_ C++ library to make a raster (pixel) image of the figure; it +# is used by the ``QtAgg``, ``GTK3Agg``, ``wxAgg``, ``TkAgg``, and +# ``macosx`` backends. An alternative renderer is based on the Cairo library, +# used by ``QtCairo``, etc. # # For the rendering engines, users can also distinguish between `vector # `_ or `raster @@ -409,8 +410,9 @@ def my_plotter(ax, data1, data2, param_dict): # ========= ================================================================ # Backend Description # ========= ================================================================ -# Qt5Agg Agg rendering in a Qt5_ canvas (requires PyQt5_). This -# backend can be activated in IPython with ``%matplotlib qt5``. +# QtAgg Agg rendering in a Qt_ canvas (requires PyQt_ or `Qt for Python`_, +# a.k.a. PySide). This backend can be activated in IPython with +# ``%matplotlib qt``. # ipympl Agg rendering embedded in a Jupyter widget. (requires ipympl). # This backend can be enabled in a Jupyter notebook with # ``%matplotlib ipympl``. @@ -433,8 +435,8 @@ def my_plotter(ax, data1, data2, param_dict): # ========= ================================================================ # # .. note:: -# The names of builtin backends are case-insensitive. For example, 'Qt5Agg' -# and 'qt5agg' are equivalent. +# The names of builtin backends case-insensitive; e.g., 'QtAgg' and +# 'qtagg' are equivalent. # # .. _`Anti-Grain Geometry`: http://antigrain.com/ # .. _`Portable Document Format`: https://en.wikipedia.org/wiki/Portable_Document_Format @@ -447,8 +449,9 @@ def my_plotter(ax, data1, data2, param_dict): # .. _cairocffi: https://pythonhosted.org/cairocffi/ # .. _wxPython: https://www.wxpython.org/ # .. _TkInter: https://docs.python.org/3/library/tk.html -# .. _PyQt5: https://riverbankcomputing.com/software/pyqt/intro -# .. _Qt5: https://doc.qt.io/qt-5/index.html +# .. _PyQt: https://riverbankcomputing.com/software/pyqt/intro +# .. _`Qt for Python`: https://doc.qt.io/qtforpython/ +# .. _Qt: https://qt.io/ # .. _GTK: https://www.gtk.org/ # .. _Tk: https://www.tcl.tk/ # .. _wxWidgets: https://www.wxwidgets.org/ From a05a2641f77a3642eed9dc94d9e1311f8eda496e Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Thu, 28 Jan 2021 10:21:53 +0100 Subject: [PATCH 03/11] Restrict Qt6 CI to Ubuntu 20.04. Also, PyGObjects built on different Ubuntus are incompatible, so the cache should be keyed by matrix.os (which includes the version number), not runner.os (which doesn't). --- .github/workflows/tests.yml | 54 ++++++++++++++------------ lib/matplotlib/tests/test_sphinxext.py | 11 ++++++ 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a225ea52971e..84c73e3e3289 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -95,6 +95,9 @@ jobs: texlive-luatex \ texlive-xetex \ ttf-wqy-zenhei + if [[ "${{ matrix.os }}" = ubuntu-20.04 ]]; then + sudo apt install -yy libopengl0 + fi ;; macOS) brew install ccache @@ -106,25 +109,25 @@ jobs: if: startsWith(runner.os, 'Linux') with: path: ~/.cache/pip - key: ${{ runner.os }}-py${{ matrix.python-version }}-pip-${{ hashFiles('requirements/*/*.txt') }} + key: ${{ matrix.os }}-py${{ matrix.python-version }}-pip-${{ hashFiles('requirements/*/*.txt') }} restore-keys: | - ${{ runner.os }}-py${{ matrix.python-version }}-pip- + ${{ matrix.os }}-py${{ matrix.python-version }}-pip- - name: Cache pip uses: actions/cache@v2 if: startsWith(runner.os, 'macOS') with: path: ~/Library/Caches/pip - key: ${{ runner.os }}-py${{ matrix.python-version }}-pip-${{ hashFiles('requirements/*/*.txt') }} + key: ${{ matrix.os }}-py${{ matrix.python-version }}-pip-${{ hashFiles('requirements/*/*.txt') }} restore-keys: | - ${{ runner.os }}-py${{ matrix.python-version }}-pip- + ${{ matrix.os }}-py${{ matrix.python-version }}-pip- - name: Cache ccache uses: actions/cache@v2 with: path: | ~/.ccache - key: ${{ runner.os }}-py${{ matrix.python-version }}-ccache-${{ hashFiles('src/*') }} + key: ${{ matrix.os }}-py${{ matrix.python-version }}-ccache-${{ hashFiles('src/*') }} restore-keys: | - ${{ runner.os }}-py${{ matrix.python-version }}-ccache- + ${{ matrix.os }}-py${{ matrix.python-version }}-ccache- - name: Cache Matplotlib uses: actions/cache@v2 with: @@ -171,25 +174,28 @@ jobs: # Sept 2020) for either pyqt5 (there are only wheels for 10.13+) or # pyside2 (the latest version (5.13.2) with 10.12 wheels has a # fatal to us bug, it was fixed in 5.14.0 which has 10.13 wheels) - python -m pip install --upgrade pyqt5${{ matrix.pyqt5-ver }} && - python -c 'import PyQt5.QtCore' && - echo 'PyQt5 is available' || - echo 'PyQt5 is not available' - python -m pip install --upgrade pyside2 && - python -c 'import PySide2.QtCore' && - echo 'PySide2 is available' || - echo 'PySide2 is not available' - python -mpip install --upgrade pyqt6 && - python -c 'import PyQt6.QtCore' && - echo 'PyQt6 is available' || - echo 'PyQt6 is not available' - python -mpip install --upgrade pyside6 && - python -c 'import PySide6.QtCore' && - echo 'PySide6 is available' || - echo 'PySide6 is not available' + python -mpip install --upgrade pyqt5${{ matrix.pyqt5-ver }} && + python -c 'import PyQt5.QtCore' && + echo 'PyQt5 is available' || + echo 'PyQt5 is not available' + python -mpip install --upgrade pyside2 && + python -c 'import PySide2.QtCore' && + echo 'PySide2 is available' || + echo 'PySide2 is not available' + # Qt6 crashes on Github's ubuntu 18.04 runner. + if [[ "${{ matrix.os }}" = ubuntu-20.04 ]]; then + python -mpip install --upgrade pyqt6 && + python -c 'import PyQt6.QtCore' && + echo 'PyQt6 is available' || + echo 'PyQt6 is not available' + python -mpip install --upgrade pyside6 && + python -c 'import PySide6.QtCore' && + echo 'PySide6 is available' || + echo 'PySide6 is not available' + fi fi - python -m pip install --upgrade \ - -f https://extras.wxpython.org/wxPython4/extras/linux/gtk3/ubuntu-$(lsb_release -r -s) \ + python -mpip install --upgrade \ + -f "https://extras.wxpython.org/wxPython4/extras/linux/gtk3/${{ matrix.os }}" \ wxPython && python -c 'import wx' && echo 'wxPython is available' || diff --git a/lib/matplotlib/tests/test_sphinxext.py b/lib/matplotlib/tests/test_sphinxext.py index c0aeb3387df0..668563ee5045 100644 --- a/lib/matplotlib/tests/test_sphinxext.py +++ b/lib/matplotlib/tests/test_sphinxext.py @@ -18,6 +18,17 @@ def test_tinypages(tmpdir): shutil.copytree(Path(__file__).parent / 'tinypages', source_dir) html_dir = source_dir / '_build' / 'html' doctree_dir = source_dir / 'doctrees' + # Build the pages with warnings turned into errors + cmd = [sys.executable, '-msphinx', '-W', '-b', 'html', + '-d', str(doctree_dir), + str(Path(__file__).parent / 'tinypages'), str(html_dir)] + # On CI, gcov emits warnings (due to agg headers being included with the + # same name in multiple extension modules -- but we don't care about their + # coverage anyways); hide them using GCOV_ERROR_FILE. + proc = Popen( + cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True, + env={**os.environ, "MPLBACKEND": "", "GCOV_ERROR_FILE": os.devnull}) + out, err = proc.communicate() # Build the pages with warnings turned into errors build_sphinx_html(source_dir, doctree_dir, html_dir) From 9af08db9a65098319593d8b73507bcb6da15bb2e Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 26 May 2021 23:31:44 -0400 Subject: [PATCH 04/11] FIX: account for PyQt6 API changes in 6.1 and set minimum version We do not exercise all of the code paths that call _enum in the tests. At least PyQt5 5.8 does not support the enums by name so we still need the version gating. --- doc/devel/dependencies.rst | 2 +- lib/matplotlib/backends/backend_qt.py | 20 +- lib/matplotlib/backends/qt_compat.py | 2 +- .../backends/qt_editor/_formlayout.py | 8 +- lib/matplotlib/tests/test_backend_qt.py | 173 +++++++++++++++++- 5 files changed, 181 insertions(+), 24 deletions(-) diff --git a/doc/devel/dependencies.rst b/doc/devel/dependencies.rst index 74682223b0f5..33a35b3dde1a 100644 --- a/doc/devel/dependencies.rst +++ b/doc/devel/dependencies.rst @@ -41,7 +41,7 @@ Matplotlib figures can be rendered to various user interfaces. See and the capabilities they provide. * Tk_ (>= 8.3, != 8.6.0 or 8.6.1) [#]_: for the Tk-based backends. -* PyQt6_, PySide6_, PyQt5_, or PySide2_: for the Qt-based backends. +* PyQt6_ (>= 6.1), PySide6_, PyQt5_, or PySide2_: for the Qt-based backends. * PyGObject_: for the GTK3-based backends [#]_. * wxPython_ (>= 4) [#]_: for the wx-based backends. * pycairo_ (>= 1.11.0) or cairocffi_ (>= 0.8): for the GTK3 and/or cairo-based diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 4c5a1966e392..dea9cac47f87 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -69,7 +69,7 @@ # Elements are (Qt::KeyboardModifiers, Qt::Key) tuples. # Order determines the modifier order (ctrl+alt+...) reported by Matplotlib. _MODIFIER_KEYS = [ - (_to_int(getattr(_enum("QtCore.Qt.KeyboardModifiers"), mod)), + (_to_int(getattr(_enum("QtCore.Qt.KeyboardModifier"), mod)), _to_int(getattr(_enum("QtCore.Qt.Key"), key))) for mod, key in [ ("ControlModifier", "Key_Control"), @@ -210,7 +210,7 @@ class FigureCanvasQT(QtWidgets.QWidget, FigureCanvasBase): _timer_cls = TimerQT buttond = { - getattr(_enum("QtCore.Qt.MouseButtons"), k): v for k, v in [ + getattr(_enum("QtCore.Qt.MouseButton"), k): v for k, v in [ ("LeftButton", MouseButton.LEFT), ("RightButton", MouseButton.RIGHT), ("MiddleButton", MouseButton.MIDDLE), @@ -633,8 +633,8 @@ def __init__(self, canvas, parent, coordinates=True): """coordinates: should we show the coordinates on the right?""" QtWidgets.QToolBar.__init__(self, parent) self.setAllowedAreas( - _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea - | _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea) + _enum("QtCore.Qt.ToolBarArea").TopToolBarArea + | _enum("QtCore.Qt.ToolBarArea").TopToolBarArea) self.coordinates = coordinates self._actions = {} # mapping of toolitem method names to QActions. @@ -658,8 +658,8 @@ def __init__(self, canvas, parent, coordinates=True): if self.coordinates: self.locLabel = QtWidgets.QLabel("", self) self.locLabel.setAlignment( - _enum("QtCore.Qt.Alignment").AlignRight - | _enum("QtCore.Qt.Alignment").AlignVCenter) + _enum("QtCore.Qt.AlignmentFlag").AlignRight + | _enum("QtCore.Qt.AlignmentFlag").AlignVCenter) self.locLabel.setSizePolicy(QtWidgets.QSizePolicy( _enum("QtWidgets.QSizePolicy.Policy").Expanding, _enum("QtWidgets.QSizePolicy.Policy").Ignored, @@ -890,12 +890,12 @@ def __init__(self, toolmanager, parent): ToolContainerBase.__init__(self, toolmanager) QtWidgets.QToolBar.__init__(self, parent) self.setAllowedAreas( - _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea - | _enum("QtCore.Qt.ToolBarAreas").TopToolBarArea) + _enum("QtCore.Qt.ToolBarArea").TopToolBarArea + | _enum("QtCore.Qt.ToolBarArea").TopToolBarArea) message_label = QtWidgets.QLabel("") message_label.setAlignment( - _enum("QtCore.Qt.Alignment").AlignRight - | _enum("QtCore.Qt.Alignment").AlignVCenter) + _enum("QtCore.Qt.AlignmentFlag").AlignRight + | _enum("QtCore.Qt.AlignmentFlag").AlignVCenter) message_label.setSizePolicy(QtWidgets.QSizePolicy( _enum("QtWidgets.QSizePolicy.Policy").Expanding, _enum("QtWidgets.QSizePolicy.Policy").Ignored, diff --git a/lib/matplotlib/backends/qt_compat.py b/lib/matplotlib/backends/qt_compat.py index f5a59fde1b8d..5cb4fb73b618 100644 --- a/lib/matplotlib/backends/qt_compat.py +++ b/lib/matplotlib/backends/qt_compat.py @@ -150,7 +150,7 @@ def _isdeleted(obj): return not shiboken2.isValid(obj) def _enum(name): # foo.bar.Enum.Entry (PyQt6) <=> foo.bar.Entry (non-PyQt6). return operator.attrgetter( - name if QT_API == "PyQt6" else name.rpartition(".")[0] + name if QT_API == 'PyQt6' else name.rpartition(".")[0] )(sys.modules[QtCore.__package__]) diff --git a/lib/matplotlib/backends/qt_editor/_formlayout.py b/lib/matplotlib/backends/qt_editor/_formlayout.py index cb3a7560bbec..9ca8963e2b23 100644 --- a/lib/matplotlib/backends/qt_editor/_formlayout.py +++ b/lib/matplotlib/backends/qt_editor/_formlayout.py @@ -441,12 +441,12 @@ def __init__(self, data, title="", comment="", # Button box self.bbox = bbox = QtWidgets.QDialogButtonBox( - _enum("QtWidgets.QDialogButtonBox.StandardButtons").Ok - | _enum("QtWidgets.QDialogButtonBox.StandardButtons").Cancel) + _enum("QtWidgets.QDialogButtonBox.StandardButton").Ok + | _enum("QtWidgets.QDialogButtonBox.StandardButton").Cancel) self.formwidget.update_buttons.connect(self.update_buttons) if self.apply_callback is not None: apply_btn = bbox.addButton( - _enum("QtWidgets.QDialogButtonBox.StandardButtons").Apply) + _enum("QtWidgets.QDialogButtonBox.StandardButton").Apply) apply_btn.clicked.connect(self.apply) bbox.accepted.connect(self.accept) @@ -471,7 +471,7 @@ def update_buttons(self): valid = False for btn_type in ["Ok", "Apply"]: btn = self.bbox.button( - getattr(_enum("QtWidgets.QDialogButtonBox.StandardButtons"), + getattr(_enum("QtWidgets.QDialogButtonBox.StandardButton"), btn_type)) if btn is not None: btn.setEnabled(valid) diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index 98a3349b587b..b4a4bf523c1a 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -1,13 +1,20 @@ import copy -from datetime import date, datetime +import importlib +import inspect +import os import signal +import subprocess +import sys + +from datetime import date, datetime from unittest import mock +import pytest + import matplotlib from matplotlib import pyplot as plt from matplotlib._pylab_helpers import Gcf - -import pytest +from matplotlib import _c_internal_utils try: @@ -143,9 +150,9 @@ def test_correct_key(backend, qt_core, qt_key, qt_mods, answer): Assert sent and caught keys are the same. """ from matplotlib.backends.qt_compat import _enum, _to_int - qt_mod = _enum("QtCore.Qt.KeyboardModifiers").NoModifier + qt_mod = _enum("QtCore.Qt.KeyboardModifier").NoModifier for mod in qt_mods: - qt_mod |= getattr(_enum("QtCore.Qt.KeyboardModifiers"), mod) + qt_mod |= getattr(_enum("QtCore.Qt.KeyboardModifier"), mod) class _Event: def isAutoRepeat(self): return False @@ -258,9 +265,7 @@ def test_figureoptions_with_datetime_axes(): datetime(year=2021, month=2, day=1) ] ax.plot(xydata, xydata) - with mock.patch( - "matplotlib.backends.qt_editor._formlayout.FormDialog.exec", - lambda self: None): + with mock.patch("matplotlib.backends.qt_compat._exec", lambda obj: None): fig.canvas.manager.toolbar.edit_parameters() @@ -318,3 +323,155 @@ def test_form_widget_get_with_datetime_and_date_fields(): datetime(year=2021, month=3, day=11), date(year=2021, month=3, day=11) ] + + +# The source of this function gets extracted and run in another process, so it +# must be fully self-contained. +def _test_enums_impl(): + import sys + + from matplotlib.backends.qt_compat import _enum, _to_int, QtCore + from matplotlib.backend_bases import cursors, MouseButton + + _enum("QtGui.QDoubleValidator.State").Acceptable + + _enum("QtWidgets.QDialogButtonBox.StandardButton").Ok + _enum("QtWidgets.QDialogButtonBox.StandardButton").Cancel + _enum("QtWidgets.QDialogButtonBox.StandardButton").Apply + for btn_type in ["Ok", "Cancel"]: + getattr(_enum("QtWidgets.QDialogButtonBox.StandardButton"), btn_type) + + _enum("QtGui.QImage.Format").Format_ARGB32_Premultiplied + _enum("QtGui.QImage.Format").Format_ARGB32_Premultiplied + # SPECIAL_KEYS are Qt::Key that do *not* return their unicode name instead + # they have manually specified names. + SPECIAL_KEYS = { + _to_int(getattr(_enum("QtCore.Qt.Key"), k)): v + for k, v in [ + ("Key_Escape", "escape"), + ("Key_Tab", "tab"), + ("Key_Backspace", "backspace"), + ("Key_Return", "enter"), + ("Key_Enter", "enter"), + ("Key_Insert", "insert"), + ("Key_Delete", "delete"), + ("Key_Pause", "pause"), + ("Key_SysReq", "sysreq"), + ("Key_Clear", "clear"), + ("Key_Home", "home"), + ("Key_End", "end"), + ("Key_Left", "left"), + ("Key_Up", "up"), + ("Key_Right", "right"), + ("Key_Down", "down"), + ("Key_PageUp", "pageup"), + ("Key_PageDown", "pagedown"), + ("Key_Shift", "shift"), + # In OSX, the control and super (aka cmd/apple) keys are switched. + ("Key_Control", "control" if sys.platform != "darwin" else "cmd"), + ("Key_Meta", "meta" if sys.platform != "darwin" else "control"), + ("Key_Alt", "alt"), + ("Key_CapsLock", "caps_lock"), + ("Key_F1", "f1"), + ("Key_F2", "f2"), + ("Key_F3", "f3"), + ("Key_F4", "f4"), + ("Key_F5", "f5"), + ("Key_F6", "f6"), + ("Key_F7", "f7"), + ("Key_F8", "f8"), + ("Key_F9", "f9"), + ("Key_F10", "f10"), + ("Key_F10", "f11"), + ("Key_F12", "f12"), + ("Key_Super_L", "super"), + ("Key_Super_R", "super"), + ] + } + # Define which modifier keys are collected on keyboard events. Elements + # are (Qt::KeyboardModifiers, Qt::Key) tuples. Order determines the + # modifier order (ctrl+alt+...) reported by Matplotlib. + _MODIFIER_KEYS = [ + ( + _to_int(getattr(_enum("QtCore.Qt.KeyboardModifier"), mod)), + _to_int(getattr(_enum("QtCore.Qt.Key"), key)), + ) + for mod, key in [ + ("ControlModifier", "Key_Control"), + ("AltModifier", "Key_Alt"), + ("ShiftModifier", "Key_Shift"), + ("MetaModifier", "Key_Meta"), + ] + ] + cursord = { + k: getattr(_enum("QtCore.Qt.CursorShape"), v) + for k, v in [ + (cursors.MOVE, "SizeAllCursor"), + (cursors.HAND, "PointingHandCursor"), + (cursors.POINTER, "ArrowCursor"), + (cursors.SELECT_REGION, "CrossCursor"), + (cursors.WAIT, "WaitCursor"), + ] + } + + buttond = { + getattr(_enum("QtCore.Qt.MouseButton"), k): v + for k, v in [ + ("LeftButton", MouseButton.LEFT), + ("RightButton", MouseButton.RIGHT), + ("MiddleButton", MouseButton.MIDDLE), + ("XButton1", MouseButton.BACK), + ("XButton2", MouseButton.FORWARD), + ] + } + + _enum("QtCore.Qt.WidgetAttribute").WA_OpaquePaintEvent + _enum("QtCore.Qt.FocusPolicy").StrongFocus + _enum("QtCore.Qt.ToolBarArea").TopToolBarArea + _enum("QtCore.Qt.ToolBarArea").TopToolBarArea + _enum("QtCore.Qt.AlignmentFlag").AlignRight + _enum("QtCore.Qt.AlignmentFlag").AlignVCenter + _enum("QtWidgets.QSizePolicy.Policy").Expanding + _enum("QtWidgets.QSizePolicy.Policy").Ignored + _enum("QtCore.Qt.MaskMode").MaskOutColor + _enum("QtCore.Qt.ToolBarArea").TopToolBarArea + _enum("QtCore.Qt.ToolBarArea").TopToolBarArea + _enum("QtCore.Qt.AlignmentFlag").AlignRight + _enum("QtCore.Qt.AlignmentFlag").AlignVCenter + _enum("QtWidgets.QSizePolicy.Policy").Expanding + _enum("QtWidgets.QSizePolicy.Policy").Ignored + + +def _get_testable_qt_backends(): + envs = [] + for deps, env in [ + ([qt_api], {"MPLBACKEND": "qtagg", "QT_API": qt_api}) + for qt_api in ["PyQt6", "PySide6", "PyQt5", "PySide2"] + ]: + reason = None + missing = [dep for dep in deps if not importlib.util.find_spec(dep)] + if (sys.platform == "linux" and + not _c_internal_utils.display_is_valid()): + reason = "$DISPLAY and $WAYLAND_DISPLAY are unset" + elif missing: + reason = "{} cannot be imported".format(", ".join(missing)) + elif env["MPLBACKEND"] == 'macosx' and os.environ.get('TF_BUILD'): + reason = "macosx backend fails on Azure" + marks = [] + if reason: + marks.append(pytest.mark.skip( + reason=f"Skipping {env} because {reason}")) + envs.append(pytest.param(env, marks=marks, id=str(env))) + return envs + +_test_timeout = 10 # Empirically, 1s is not enough on CI. + + +@pytest.mark.parametrize("env", _get_testable_qt_backends()) +def test_enums_available(env): + proc = subprocess.run( + [sys.executable, "-c", + inspect.getsource(_test_enums_impl) + "\n_test_enums_impl()"], + env={**os.environ, "SOURCE_DATE_EPOCH": "0", **env}, + timeout=_test_timeout, check=True, + stdout=subprocess.PIPE, universal_newlines=True) From d981d262955d53c4a51d8ced61af665c6a117d65 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 26 May 2021 23:50:13 -0400 Subject: [PATCH 05/11] TST: switch order of tests Perfer to run with Qt6 if available --- lib/matplotlib/tests/test_backend_qt.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index b4a4bf523c1a..a8b83ba94f36 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -35,12 +35,12 @@ def qt_core(request): @pytest.mark.parametrize('backend', [ # Note: the value is irrelevant; the important part is the marker. - pytest.param( - 'Qt5Agg', - marks=pytest.mark.backend('Qt5Agg', skip_on_importerror=True)), pytest.param( 'QtAgg', marks=pytest.mark.backend('QtAgg', skip_on_importerror=True)), + pytest.param( + 'Qt5Agg', + marks=pytest.mark.backend('Qt5Agg', skip_on_importerror=True)), ]) def test_fig_close(backend): # save the state of Gcf.figs From b8856eaa58bed538e695444bd3d94efcd22ac6a6 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 26 May 2021 23:50:38 -0400 Subject: [PATCH 06/11] TST: PySide6 + qtagg is threadsafe (enough) --- lib/matplotlib/tests/test_backends_interactive.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 9a84fecf5c21..d524306478ad 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -220,9 +220,6 @@ def _test_thread_impl(): elif param.values[0].get("QT_API") == "PySide2": param.marks.append( pytest.mark.xfail(raises=subprocess.CalledProcessError)) - elif param.values[0].get("QT_API") == "PySide6": - param.marks.append( - pytest.mark.xfail(raises=subprocess.TimeoutExpired)) @pytest.mark.parametrize("env", _thread_safe_backends) From fbe46a83e1a62848fb5929478e6fa03569180dda Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 29 Jun 2021 23:24:18 -0400 Subject: [PATCH 07/11] TST: remove redundant parametrization Just run with what ever version of PyQt we find. --- lib/matplotlib/tests/test_backend_qt.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index a8b83ba94f36..9b9a89839a5b 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -33,15 +33,7 @@ def qt_core(request): return QtCore -@pytest.mark.parametrize('backend', [ - # Note: the value is irrelevant; the important part is the marker. - pytest.param( - 'QtAgg', - marks=pytest.mark.backend('QtAgg', skip_on_importerror=True)), - pytest.param( - 'Qt5Agg', - marks=pytest.mark.backend('Qt5Agg', skip_on_importerror=True)), -]) +@pytest.mark.backend('QtAgg', skip_on_importerror=True) def test_fig_close(backend): # save the state of Gcf.figs init_figs = copy.copy(Gcf.figs) From 006aa743a644fd4795c35378c9df29c842279cf9 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 29 Jun 2021 23:53:42 -0400 Subject: [PATCH 08/11] MNT: re-expose more symbols in back-compat modules --- lib/matplotlib/backends/backend_qt5.py | 11 ++++++++--- lib/matplotlib/backends/backend_qt5agg.py | 3 ++- lib/matplotlib/backends/backend_qt5cairo.py | 6 +++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/backends/backend_qt5.py b/lib/matplotlib/backends/backend_qt5.py index fa7e8a7d3671..a6e368ea384d 100644 --- a/lib/matplotlib/backends/backend_qt5.py +++ b/lib/matplotlib/backends/backend_qt5.py @@ -1,10 +1,15 @@ from .backend_qt import ( backend_version, SPECIAL_KEYS, - SUPER, ALT, CTRL, SHIFT, MODIFIER_KEYS, # These are deprecated. + # These are deprecated. + SUPER, ALT, CTRL, SHIFT, MODIFIER_KEYS as _MODIFIER_KEYS, + # Public API cursord, _create_qApp, _BackendQT, TimerQT, MainWindow, FigureCanvasQT, - FigureManagerQT, NavigationToolbar2QT, SubplotToolQt, + FigureManagerQT, ToolbarQt, NavigationToolbar2QT, SubplotToolQt, SaveFigureQt, ConfigureSubplotsQt, SetCursorQt, RubberbandQt, - HelpQt, ToolCopyToClipboardQt + HelpQt, ToolCopyToClipboardQT, + # internal re-exports + FigureCanvasBase, FigureManagerBase, MouseButton, NavigationToolbar2, + TimerBase, ToolContainerBase, figureoptions, Gcf ) diff --git a/lib/matplotlib/backends/backend_qt5agg.py b/lib/matplotlib/backends/backend_qt5agg.py index faf3dabaef8c..d176fbe82bfb 100644 --- a/lib/matplotlib/backends/backend_qt5agg.py +++ b/lib/matplotlib/backends/backend_qt5agg.py @@ -4,7 +4,8 @@ from .backend_qtagg import ( _BackendQTAgg, FigureCanvasQTAgg, FigureManagerQT, NavigationToolbar2QT, - backend_version) + backend_version, FigureCanvasAgg, FigureCanvasQT +) @_BackendQTAgg.export diff --git a/lib/matplotlib/backends/backend_qt5cairo.py b/lib/matplotlib/backends/backend_qt5cairo.py index 5f8c20202868..51eae512c654 100644 --- a/lib/matplotlib/backends/backend_qt5cairo.py +++ b/lib/matplotlib/backends/backend_qt5cairo.py @@ -1,4 +1,8 @@ -from .backend_qtcairo import _BackendQTCairo, FigureCanvasQTCairo +from .backend_qtcairo import ( + _BackendQTCairo, FigureCanvasQTCairo, + FigureCanvasCairo, FigureCanvasQT, + RendererCairo +) @_BackendQTCairo.export From 8b4c2a7e2f6601ffca8f003a2747b81140cc325a Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 4 Aug 2021 17:06:01 -0400 Subject: [PATCH 09/11] TST: fix test parameterize / mark Used to be parameterized, now just marked. There is no backend fixture. --- lib/matplotlib/tests/test_backend_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index 9b9a89839a5b..0d3afc8173c4 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -34,7 +34,7 @@ def qt_core(request): @pytest.mark.backend('QtAgg', skip_on_importerror=True) -def test_fig_close(backend): +def test_fig_close(): # save the state of Gcf.figs init_figs = copy.copy(Gcf.figs) From 146872d100f4cf68e8ccad79c304599d68c00a34 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 5 Aug 2021 10:04:07 -0400 Subject: [PATCH 10/11] MNT: fix error message for invalid QT_API env --- lib/matplotlib/backends/qt_compat.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/backends/qt_compat.py b/lib/matplotlib/backends/qt_compat.py index 5cb4fb73b618..d90d63fe3473 100644 --- a/lib/matplotlib/backends/qt_compat.py +++ b/lib/matplotlib/backends/qt_compat.py @@ -67,9 +67,10 @@ QT_API = _ETS[QT_API_ENV] except KeyError as err: raise RuntimeError( - "The environment variable QT_API has the unrecognized value {!r};" - "valid values are {}".format( - QT_API, ", ".join(map(repr, _ETS)))) from None + "The environment variable QT_API has the unrecognized value " + f"{QT_API_ENV!r}; " + f"valid values are {set(k for k in _ETS if k is not None)}" + ) from None def _setup_pyqt5plus(): From 4c354656bc95ade3cd69f01e0b262110c9b2735f Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 5 Aug 2021 13:17:50 -0400 Subject: [PATCH 11/11] TST: remove un-needed special case If pyqt4 is imported the code just above will catch the problem and if pyqt4 is not imported we can rely on `plt.switch_backend` to handle the fallback between pyqt/pyside versions. --- lib/matplotlib/testing/conftest.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/matplotlib/testing/conftest.py b/lib/matplotlib/testing/conftest.py index d0aa85367529..fe6cd3235fd2 100644 --- a/lib/matplotlib/testing/conftest.py +++ b/lib/matplotlib/testing/conftest.py @@ -53,14 +53,6 @@ def mpl_test_settings(request): if backend.lower().startswith('qt5'): if any(sys.modules.get(k) for k in ('PyQt4', 'PySide')): pytest.skip('Qt4 binding already imported') - try: - import PyQt5 - # RuntimeError if PyQt4 already imported. - except (ImportError, RuntimeError): - try: - import PySide2 - except ImportError: - pytest.skip("Failed to import a Qt5 binding.") # Default of cleanup and image_comparison too. style = ["classic", "_classic_test_patch"]