Skip to content

Commit f61c7d4

Browse files
committed
Add "experimental" 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.
1 parent bf0f6cc commit f61c7d4

File tree

6 files changed

+206
-145
lines changed

6 files changed

+206
-145
lines changed

lib/matplotlib/backends/backend_qt5.py

+112-94
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import functools
22
import importlib
3+
import operator
34
import os
45
import signal
56
import sys
@@ -15,70 +16,77 @@
1516
from matplotlib.backends.qt_editor._formsubplottool import UiSubplotTool
1617
from . import qt_compat
1718
from .qt_compat import (
18-
QtCore, QtGui, QtWidgets, __version__, QT_API,
19+
QtCore, QtGui, QtWidgets, Qt, __version__, QT_API,
1920
_devicePixelRatioF, _isdeleted, _setDevicePixelRatio,
2021
)
2122

2223
backend_version = __version__
2324

24-
# SPECIAL_KEYS are keys that do *not* return their unicode name
25-
# instead they have manually specified names
26-
SPECIAL_KEYS = {QtCore.Qt.Key_Control: 'control',
27-
QtCore.Qt.Key_Shift: 'shift',
28-
QtCore.Qt.Key_Alt: 'alt',
29-
QtCore.Qt.Key_Meta: 'super',
30-
QtCore.Qt.Key_Return: 'enter',
31-
QtCore.Qt.Key_Left: 'left',
32-
QtCore.Qt.Key_Up: 'up',
33-
QtCore.Qt.Key_Right: 'right',
34-
QtCore.Qt.Key_Down: 'down',
35-
QtCore.Qt.Key_Escape: 'escape',
36-
QtCore.Qt.Key_F1: 'f1',
37-
QtCore.Qt.Key_F2: 'f2',
38-
QtCore.Qt.Key_F3: 'f3',
39-
QtCore.Qt.Key_F4: 'f4',
40-
QtCore.Qt.Key_F5: 'f5',
41-
QtCore.Qt.Key_F6: 'f6',
42-
QtCore.Qt.Key_F7: 'f7',
43-
QtCore.Qt.Key_F8: 'f8',
44-
QtCore.Qt.Key_F9: 'f9',
45-
QtCore.Qt.Key_F10: 'f10',
46-
QtCore.Qt.Key_F11: 'f11',
47-
QtCore.Qt.Key_F12: 'f12',
48-
QtCore.Qt.Key_Home: 'home',
49-
QtCore.Qt.Key_End: 'end',
50-
QtCore.Qt.Key_PageUp: 'pageup',
51-
QtCore.Qt.Key_PageDown: 'pagedown',
52-
QtCore.Qt.Key_Tab: 'tab',
53-
QtCore.Qt.Key_Backspace: 'backspace',
54-
QtCore.Qt.Key_Enter: 'enter',
55-
QtCore.Qt.Key_Insert: 'insert',
56-
QtCore.Qt.Key_Delete: 'delete',
57-
QtCore.Qt.Key_Pause: 'pause',
58-
QtCore.Qt.Key_SysReq: 'sysreq',
59-
QtCore.Qt.Key_Clear: 'clear', }
25+
# Enums are specified using numeric values because 1) they are only available
26+
# as enum attributes on PyQt6 and only available as Qt attributes on PyQt<5.11;
27+
# 2) Foos = QFlags<Foo> is exported as Qt.Foos on PyQt6 but Qt.Foo on PyQt5.
28+
29+
# SPECIAL_KEYS are Qt::Key that do *not* return their unicode name
30+
# instead they have manually specified names.
31+
SPECIAL_KEYS = {
32+
0x1000000: 'escape',
33+
0x1000001: 'tab',
34+
0x1000003: 'backspace',
35+
0x1000004: 'enter',
36+
0x1000005: 'enter',
37+
0x1000006: 'insert',
38+
0x1000007: 'delete',
39+
0x1000008: 'pause',
40+
0x100000a: 'sysreq',
41+
0x100000b: 'clear',
42+
0x1000010: 'home',
43+
0x1000011: 'end',
44+
0x1000012: 'left',
45+
0x1000013: 'up',
46+
0x1000014: 'right',
47+
0x1000015: 'down',
48+
0x1000016: 'pageup',
49+
0x1000017: 'pagedown',
50+
0x1000020: 'shift',
51+
0x1000021: 'control',
52+
0x1000022: 'super',
53+
0x1000023: 'alt',
54+
0x1000030: 'f1',
55+
0x1000031: 'f2',
56+
0x1000032: 'f3',
57+
0x1000033: 'f4',
58+
0x1000034: 'f5',
59+
0x1000035: 'f6',
60+
0x1000036: 'f7',
61+
0x1000037: 'f8',
62+
0x1000038: 'f9',
63+
0x1000039: 'f10',
64+
0x100003a: 'f11',
65+
0x100003b: 'f12',
66+
}
6067
if sys.platform == 'darwin':
6168
# in OSX, the control and super (aka cmd/apple) keys are switched, so
6269
# switch them back.
63-
SPECIAL_KEYS.update({QtCore.Qt.Key_Control: 'cmd', # cmd/apple key
64-
QtCore.Qt.Key_Meta: 'control',
65-
})
70+
SPECIAL_KEYS.update({
71+
0x01000021: 'cmd', # cmd/apple key
72+
0x01000022: 'control',
73+
})
6674
# Define which modifier keys are collected on keyboard events.
67-
# Elements are (Modifier Flag, Qt Key) tuples.
75+
# Elements are (Qt::KeyboardModifier(s), Qt::Key) tuples.
6876
# Order determines the modifier order (ctrl+alt+...) reported by Matplotlib.
6977
_MODIFIER_KEYS = [
70-
(QtCore.Qt.ShiftModifier, QtCore.Qt.Key_Shift),
71-
(QtCore.Qt.ControlModifier, QtCore.Qt.Key_Control),
72-
(QtCore.Qt.AltModifier, QtCore.Qt.Key_Alt),
73-
(QtCore.Qt.MetaModifier, QtCore.Qt.Key_Meta),
78+
(0x02000000, 0x01000020), # shift
79+
(0x04000000, 0x01000021), # control
80+
(0x08000000, 0x01000023), # alt
81+
(0x10000000, 0x01000022), # meta
7482
]
7583
cursord = {
76-
cursors.MOVE: QtCore.Qt.SizeAllCursor,
77-
cursors.HAND: QtCore.Qt.PointingHandCursor,
78-
cursors.POINTER: QtCore.Qt.ArrowCursor,
79-
cursors.SELECT_REGION: QtCore.Qt.CrossCursor,
80-
cursors.WAIT: QtCore.Qt.WaitCursor,
81-
}
84+
cursors.MOVE: Qt.CursorShape(9), # SizeAllCursor
85+
cursors.HAND: Qt.CursorShape(13), # PointingHandCursor
86+
cursors.POINTER: Qt.CursorShape(0), # ArrowCursor
87+
cursors.SELECT_REGION: Qt.CursorShape(2), # CrossCursor
88+
cursors.WAIT: Qt.CursorShape(3), # WaitCursor
89+
}
8290
SUPER = 0 # Deprecated.
8391
ALT = 1 # Deprecated.
8492
CTRL = 2 # Deprecated.
@@ -87,6 +95,10 @@
8795
(SPECIAL_KEYS[key], mod, key) for mod, key in _MODIFIER_KEYS]
8896

8997

98+
def _to_int(x):
99+
return x.value if QT_API == "PyQt6" else int(x)
100+
101+
90102
# make place holder
91103
qApp = None
92104

@@ -140,17 +152,17 @@ def _allow_super_init(__init__):
140152
Decorator for ``__init__`` to allow ``super().__init__`` on PyQt4/PySide2.
141153
"""
142154

143-
if QT_API == "PyQt5":
155+
if QT_API in ["PyQt5", "PyQt6"]:
144156

145157
return __init__
146158

147159
else:
148-
# To work around lack of cooperative inheritance in PyQt4, PySide,
149-
# and PySide2, when calling FigureCanvasQT.__init__, we temporarily
160+
# To work around lack of cooperative inheritance in PyQt4 and
161+
# PySide{,2,6}, when calling FigureCanvasQT.__init__, we temporarily
150162
# patch QWidget.__init__ by a cooperative version, that first calls
151163
# QWidget.__init__ with no additional arguments, and then finds the
152164
# next class in the MRO with an __init__ that does support cooperative
153-
# inheritance (i.e., not defined by the PyQt4, PySide, PySide2, sip
165+
# inheritance (i.e., not defined by the PyQt4 or sip, or PySide{,2,6}
154166
# or Shiboken packages), and manually call its `__init__`, once again
155167
# passing the additional arguments.
156168

@@ -162,7 +174,9 @@ def cooperative_qwidget_init(self, *args, **kwargs):
162174
next_coop_init = next(
163175
cls for cls in mro[mro.index(QtWidgets.QWidget) + 1:]
164176
if cls.__module__.split(".")[0] not in [
165-
"PyQt4", "sip", "PySide", "PySide2", "Shiboken"])
177+
"PyQt4", "sip",
178+
"PySide", "PySide2", "PySide6", "Shiboken",
179+
])
166180
next_coop_init.__init__(self, *args, **kwargs)
167181

168182
@functools.wraps(__init__)
@@ -207,13 +221,13 @@ class FigureCanvasQT(QtWidgets.QWidget, FigureCanvasBase):
207221
required_interactive_framework = "qt5"
208222
_timer_cls = TimerQT
209223

210-
# map Qt button codes to MouseEvent's ones:
211-
buttond = {QtCore.Qt.LeftButton: MouseButton.LEFT,
212-
QtCore.Qt.MidButton: MouseButton.MIDDLE,
213-
QtCore.Qt.RightButton: MouseButton.RIGHT,
214-
QtCore.Qt.XButton1: MouseButton.BACK,
215-
QtCore.Qt.XButton2: MouseButton.FORWARD,
216-
}
224+
buttond = { # Map Qt::MouseButton(s) to MouseEvents.
225+
0x01: MouseButton.LEFT,
226+
0x02: MouseButton.RIGHT,
227+
0x04: MouseButton.MIDDLE,
228+
0x08: MouseButton.BACK,
229+
0x10: MouseButton.FORWARD,
230+
}
217231

218232
@_allow_super_init
219233
def __init__(self, figure):
@@ -233,11 +247,11 @@ def __init__(self, figure):
233247
self._is_drawing = False
234248
self._draw_rect_callback = lambda painter: None
235249

236-
self.setAttribute(QtCore.Qt.WA_OpaquePaintEvent)
250+
self.setAttribute(4) # Qt.WidgetAttribute.WA_OpaquePaintEvent
237251
self.setMouseTracking(True)
238252
self.resize(*self.get_width_height())
239253

240-
palette = QtGui.QPalette(QtCore.Qt.white)
254+
palette = QtGui.QPalette(QtGui.QColor("white"))
241255
self.setPalette(palette)
242256

243257
def _update_figure_dpi(self):
@@ -283,7 +297,7 @@ def get_width_height(self):
283297

284298
def enterEvent(self, event):
285299
try:
286-
x, y = self.mouseEventCoords(event.pos())
300+
x, y = self.mouseEventCoords(self._get_position(event))
287301
except AttributeError:
288302
# the event from PyQt4 does not include the position
289303
x = y = None
@@ -293,6 +307,9 @@ def leaveEvent(self, event):
293307
QtWidgets.QApplication.restoreOverrideCursor()
294308
FigureCanvasBase.leave_notify_event(self, guiEvent=event)
295309

310+
_get_position = operator.methodcaller(
311+
"position" if QT_API in ["PyQt6", "PySide6"] else "pos")
312+
296313
def mouseEventCoords(self, pos):
297314
"""
298315
Calculate mouse coordinates in physical pixels.
@@ -310,34 +327,34 @@ def mouseEventCoords(self, pos):
310327
return x * dpi_ratio, y * dpi_ratio
311328

312329
def mousePressEvent(self, event):
313-
x, y = self.mouseEventCoords(event.pos())
314-
button = self.buttond.get(event.button())
330+
x, y = self.mouseEventCoords(self._get_position(event))
331+
button = self.buttond.get(_to_int(event.button()))
315332
if button is not None:
316333
FigureCanvasBase.button_press_event(self, x, y, button,
317334
guiEvent=event)
318335

319336
def mouseDoubleClickEvent(self, event):
320-
x, y = self.mouseEventCoords(event.pos())
321-
button = self.buttond.get(event.button())
337+
x, y = self.mouseEventCoords(self._get_position(event))
338+
button = self.buttond.get(_to_int(event.button()))
322339
if button is not None:
323340
FigureCanvasBase.button_press_event(self, x, y,
324341
button, dblclick=True,
325342
guiEvent=event)
326343

327344
def mouseMoveEvent(self, event):
328-
x, y = self.mouseEventCoords(event)
345+
x, y = self.mouseEventCoords(self._get_position(event))
329346
FigureCanvasBase.motion_notify_event(self, x, y, guiEvent=event)
330347

331348
def mouseReleaseEvent(self, event):
332-
x, y = self.mouseEventCoords(event)
333-
button = self.buttond.get(event.button())
349+
x, y = self.mouseEventCoords(self._get_position(event))
350+
button = self.buttond.get(_to_int(event.button()))
334351
if button is not None:
335352
FigureCanvasBase.button_release_event(self, x, y, button,
336353
guiEvent=event)
337354

338355
if QtCore.qVersion() >= "5.":
339356
def wheelEvent(self, event):
340-
x, y = self.mouseEventCoords(event)
357+
x, y = self.mouseEventCoords(self._get_position(event))
341358
# from QWheelEvent::delta doc
342359
if event.pixelDelta().x() == 0 and event.pixelDelta().y() == 0:
343360
steps = event.angleDelta().y() / 120
@@ -368,6 +385,9 @@ def keyReleaseEvent(self, event):
368385
FigureCanvasBase.key_release_event(self, key, guiEvent=event)
369386

370387
def resizeEvent(self, event):
388+
frame = sys._getframe()
389+
if frame.f_code is frame.f_back.f_code: # Prevent PyQt6 recursion.
390+
return
371391
w = event.size().width() * self._dpi_ratio
372392
h = event.size().height() * self._dpi_ratio
373393
dpival = self.figure.dpi
@@ -388,7 +408,7 @@ def minumumSizeHint(self):
388408

389409
def _get_key(self, event):
390410
event_key = event.key()
391-
event_mods = int(event.modifiers()) # actually a bitmask
411+
event_mods = _to_int(event.modifiers()) # actually a bitmask
392412

393413
# get names of the pressed modifier keys
394414
# 'control' is named 'control' when a standalone key, but 'ctrl' when a
@@ -433,7 +453,7 @@ def start_event_loop(self, timeout=0):
433453
if timeout > 0:
434454
timer = QtCore.QTimer.singleShot(int(timeout * 1000),
435455
event_loop.quit)
436-
event_loop.exec_()
456+
qt_compat._exec(event_loop)
437457

438458
def stop_event_loop(self, event=None):
439459
# docstring inherited
@@ -575,7 +595,7 @@ def __init__(self, canvas, num):
575595
# StrongFocus accepts both tab and click to focus and will enable the
576596
# canvas to process event without clicking.
577597
# https://doc.qt.io/qt-5/qt.html#FocusPolicy-enum
578-
self.canvas.setFocusPolicy(QtCore.Qt.StrongFocus)
598+
self.canvas.setFocusPolicy(0x1 | 0x2 | 0x8) # StrongFocus
579599
self.canvas.setFocus()
580600

581601
self.window.raise_()
@@ -654,8 +674,8 @@ class NavigationToolbar2QT(NavigationToolbar2, QtWidgets.QToolBar):
654674
def __init__(self, canvas, parent, coordinates=True):
655675
"""coordinates: should we show the coordinates on the right?"""
656676
QtWidgets.QToolBar.__init__(self, parent)
657-
self.setAllowedAreas(
658-
QtCore.Qt.TopToolBarArea | QtCore.Qt.BottomToolBarArea)
677+
self.setAllowedAreas( # Qt::TopToolBarArea | BottomToolBarArea
678+
Qt.ToolBarAreas(0x4 | 0x8))
659679

660680
self.coordinates = coordinates
661681
self._actions = {} # mapping of toolitem method names to QActions.
@@ -677,11 +697,10 @@ def __init__(self, canvas, parent, coordinates=True):
677697
# will resize this label instead of the buttons.
678698
if self.coordinates:
679699
self.locLabel = QtWidgets.QLabel("", self)
680-
self.locLabel.setAlignment(
681-
QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
700+
self.locLabel.setAlignment( # Qt::AlignRight | AlignVCenter
701+
Qt.Alignment(0x02 | 0x80))
682702
self.locLabel.setSizePolicy(
683-
QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding,
684-
QtWidgets.QSizePolicy.Ignored))
703+
QtWidgets.QSizePolicy(7, 8)) # Expanding, Ignored
685704
labelAction = self.addWidget(self.locLabel)
686705
labelAction.setVisible(True)
687706

@@ -714,8 +733,8 @@ def _icon(self, name):
714733
_setDevicePixelRatio(pm, _devicePixelRatioF(self))
715734
if self.palette().color(self.backgroundRole()).value() < 128:
716735
icon_color = self.palette().color(self.foregroundRole())
717-
mask = pm.createMaskFromColor(QtGui.QColor('black'),
718-
QtCore.Qt.MaskOutColor)
736+
mask = pm.createMaskFromColor(
737+
QtGui.QColor('black'), 1) # Qt.MaskMode.MaskOutColor
719738
pm.fill(icon_color)
720739
pm.setMask(mask)
721740
return QtGui.QIcon(pm)
@@ -785,7 +804,7 @@ def configure_subplots(self):
785804
image = str(cbook._get_data_path('images/matplotlib.png'))
786805
dia = SubplotToolQt(self.canvas.figure, self.canvas.parent())
787806
dia.setWindowIcon(QtGui.QIcon(image))
788-
dia.exec_()
807+
qt_compat._exec(dia)
789808

790809
def save_figure(self, *args):
791810
filetypes = self.canvas.get_supported_filetypes_grouped()
@@ -874,7 +893,7 @@ def _export_values(self):
874893
QtGui.QFontMetrics(text.document().defaultFont())
875894
.size(0, text.toPlainText()).height() + 20)
876895
text.setMaximumSize(size)
877-
dialog.exec_()
896+
qt_compat._exec(dialog)
878897

879898
def _on_value_changed(self):
880899
self._figure.subplots_adjust(**{attr: self._widgets[attr].value()
@@ -899,14 +918,13 @@ class ToolbarQt(ToolContainerBase, QtWidgets.QToolBar):
899918
def __init__(self, toolmanager, parent):
900919
ToolContainerBase.__init__(self, toolmanager)
901920
QtWidgets.QToolBar.__init__(self, parent)
902-
self.setAllowedAreas(
903-
QtCore.Qt.TopToolBarArea | QtCore.Qt.BottomToolBarArea)
921+
self.setAllowedAreas( # Qt::TopToolBarArea | BottomToolBarArea
922+
Qt.ToolBarAreas(0x4 | 0x8))
904923
message_label = QtWidgets.QLabel("")
905-
message_label.setAlignment(
906-
QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
924+
message_label.setAlignment( # Qt::AlignRight | AlignVCenter
925+
Qt.Alignment(0x02 | 0x80))
907926
message_label.setSizePolicy(
908-
QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding,
909-
QtWidgets.QSizePolicy.Ignored))
927+
QtWidgets.QSizePolicy(7, 8)) # Expanding, Ignored
910928
self._message_action = self.addWidget(message_label)
911929
self._toolitems = {}
912930
self._groups = {}
@@ -1031,7 +1049,7 @@ def mainloop():
10311049
if is_python_signal_handler:
10321050
signal.signal(signal.SIGINT, signal.SIG_DFL)
10331051
try:
1034-
qApp.exec_()
1052+
qt_compat._exec(qApp)
10351053
finally:
10361054
# reset the SIGINT exception handler
10371055
if is_python_signal_handler:

0 commit comments

Comments
 (0)