Skip to content

Commit 46dbac2

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 (but the new locations are also compatible with Qt5). PyQt6 mouse interactions are currently broken because its version of QMouseEvent doesn't have an `x()` method. I assume that's just an oversight (the method still exists at the C level) and will be fixed in a future bugfix, so we should at least wait a bit for that.
1 parent 2631206 commit 46dbac2

File tree

4 files changed

+173
-116
lines changed

4 files changed

+173
-116
lines changed

lib/matplotlib/backends/backend_qt5.py

+100-85
Original file line numberDiff line numberDiff line change
@@ -15,70 +15,76 @@
1515
from matplotlib.backends.qt_editor._formsubplottool import UiSubplotTool
1616
from . import qt_compat
1717
from .qt_compat import (
18-
QtCore, QtGui, QtWidgets, __version__, QT_API,
18+
QtCore, QtGui, QtWidgets, Qt, __version__, QT_API,
1919
_devicePixelRatioF, _isdeleted, _setDevicePixelRatio,
2020
)
2121

2222
backend_version = __version__
2323

24+
# Foos = QFlags<Foo> are exported as Qt.Foos on PyQt6 but Qt.Foo on PyQt5, so
25+
# we just hard-code numeric values for simplicity.
26+
2427
# SPECIAL_KEYS are keys that do *not* return their unicode name
2528
# 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', }
29+
SPECIAL_KEYS = {
30+
Qt.Key.Key_Control: 'control',
31+
Qt.Key.Key_Shift: 'shift',
32+
Qt.Key.Key_Alt: 'alt',
33+
Qt.Key.Key_Meta: 'super',
34+
Qt.Key.Key_Return: 'enter',
35+
Qt.Key.Key_Left: 'left',
36+
Qt.Key.Key_Up: 'up',
37+
Qt.Key.Key_Right: 'right',
38+
Qt.Key.Key_Down: 'down',
39+
Qt.Key.Key_Escape: 'escape',
40+
Qt.Key.Key_F1: 'f1',
41+
Qt.Key.Key_F2: 'f2',
42+
Qt.Key.Key_F3: 'f3',
43+
Qt.Key.Key_F4: 'f4',
44+
Qt.Key.Key_F5: 'f5',
45+
Qt.Key.Key_F6: 'f6',
46+
Qt.Key.Key_F7: 'f7',
47+
Qt.Key.Key_F8: 'f8',
48+
Qt.Key.Key_F9: 'f9',
49+
Qt.Key.Key_F10: 'f10',
50+
Qt.Key.Key_F11: 'f11',
51+
Qt.Key.Key_F12: 'f12',
52+
Qt.Key.Key_Home: 'home',
53+
Qt.Key.Key_End: 'end',
54+
Qt.Key.Key_PageUp: 'pageup',
55+
Qt.Key.Key_PageDown: 'pagedown',
56+
Qt.Key.Key_Tab: 'tab',
57+
Qt.Key.Key_Backspace: 'backspace',
58+
Qt.Key.Key_Enter: 'enter',
59+
Qt.Key.Key_Insert: 'insert',
60+
Qt.Key.Key_Delete: 'delete',
61+
Qt.Key.Key_Pause: 'pause',
62+
Qt.Key.Key_SysReq: 'sysreq',
63+
Qt.Key.Key_Clear: 'clear',
64+
}
6065
if sys.platform == 'darwin':
6166
# in OSX, the control and super (aka cmd/apple) keys are switched, so
6267
# switch them back.
63-
SPECIAL_KEYS.update({QtCore.Qt.Key_Control: 'cmd', # cmd/apple key
64-
QtCore.Qt.Key_Meta: 'control',
65-
})
68+
SPECIAL_KEYS.update({
69+
Qt.Key.Key_Control: 'cmd', # cmd/apple key
70+
Qt.Key.Key_Meta: 'control',
71+
})
6672
# Define which modifier keys are collected on keyboard events.
67-
# Elements are (Modifier Flag, Qt Key) tuples.
73+
# Elements are (Qt::KeyboardModifier(s), Qt Key) tuples.
6874
# Order determines the modifier order (ctrl+alt+...) reported by Matplotlib.
6975
_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),
76+
(0x02000000, Qt.Key.Key_Shift),
77+
(0x04000000, Qt.Key.Key_Control),
78+
(0x08000000, Qt.Key.Key_Alt),
79+
(0x10000000, Qt.Key.Key_Meta),
7480
]
7581
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-
}
82+
cursors.MOVE: Qt.CursorShape.SizeAllCursor,
83+
cursors.HAND: Qt.CursorShape.PointingHandCursor,
84+
cursors.POINTER: Qt.CursorShape.ArrowCursor,
85+
cursors.SELECT_REGION: Qt.CursorShape.CrossCursor,
86+
cursors.WAIT: Qt.CursorShape.WaitCursor,
87+
}
8288
SUPER = 0 # Deprecated.
8389
ALT = 1 # Deprecated.
8490
CTRL = 2 # Deprecated.
@@ -87,6 +93,10 @@
8793
(SPECIAL_KEYS[key], mod, key) for mod, key in _MODIFIER_KEYS]
8894

8995

96+
def _to_int(x):
97+
return x.value if QT_API == "PyQt6" else int(x)
98+
99+
90100
# make place holder
91101
qApp = None
92102

@@ -140,17 +150,17 @@ def _allow_super_init(__init__):
140150
Decorator for ``__init__`` to allow ``super().__init__`` on PyQt4/PySide2.
141151
"""
142152

143-
if QT_API == "PyQt5":
153+
if QT_API in ["PyQt5", "PyQt6"]:
144154

145155
return __init__
146156

147157
else:
148-
# To work around lack of cooperative inheritance in PyQt4, PySide,
149-
# and PySide2, when calling FigureCanvasQT.__init__, we temporarily
158+
# To work around lack of cooperative inheritance in PyQt4 and
159+
# PySide{,2,6}, when calling FigureCanvasQT.__init__, we temporarily
150160
# patch QWidget.__init__ by a cooperative version, that first calls
151161
# QWidget.__init__ with no additional arguments, and then finds the
152162
# next class in the MRO with an __init__ that does support cooperative
153-
# inheritance (i.e., not defined by the PyQt4, PySide, PySide2, sip
163+
# inheritance (i.e., not defined by the PyQt4 or sip, or PySide{,2,6}
154164
# or Shiboken packages), and manually call its `__init__`, once again
155165
# passing the additional arguments.
156166

@@ -162,7 +172,9 @@ def cooperative_qwidget_init(self, *args, **kwargs):
162172
next_coop_init = next(
163173
cls for cls in mro[mro.index(QtWidgets.QWidget) + 1:]
164174
if cls.__module__.split(".")[0] not in [
165-
"PyQt4", "sip", "PySide", "PySide2", "Shiboken"])
175+
"PyQt4", "sip",
176+
"PySide", "PySide2", "PySide6", "Shiboken",
177+
])
166178
next_coop_init.__init__(self, *args, **kwargs)
167179

168180
@functools.wraps(__init__)
@@ -207,13 +219,13 @@ class FigureCanvasQT(QtWidgets.QWidget, FigureCanvasBase):
207219
required_interactive_framework = "qt5"
208220
_timer_cls = TimerQT
209221

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-
}
222+
buttond = { # Map Qt::MouseButton(s) to MouseEvents.
223+
0x01: MouseButton.LEFT,
224+
0x02: MouseButton.RIGHT,
225+
0x04: MouseButton.MIDDLE,
226+
0x08: MouseButton.BACK,
227+
0x10: MouseButton.FORWARD,
228+
}
217229

218230
@_allow_super_init
219231
def __init__(self, figure):
@@ -233,11 +245,11 @@ def __init__(self, figure):
233245
self._is_drawing = False
234246
self._draw_rect_callback = lambda painter: None
235247

236-
self.setAttribute(QtCore.Qt.WA_OpaquePaintEvent)
248+
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent)
237249
self.setMouseTracking(True)
238250
self.resize(*self.get_width_height())
239251

240-
palette = QtGui.QPalette(QtCore.Qt.white)
252+
palette = QtGui.QPalette(QtGui.QColor("white"))
241253
self.setPalette(palette)
242254

243255
def _update_figure_dpi(self):
@@ -311,14 +323,14 @@ def mouseEventCoords(self, pos):
311323

312324
def mousePressEvent(self, event):
313325
x, y = self.mouseEventCoords(event.pos())
314-
button = self.buttond.get(event.button())
326+
button = self.buttond.get(_to_int(event.button()))
315327
if button is not None:
316328
FigureCanvasBase.button_press_event(self, x, y, button,
317329
guiEvent=event)
318330

319331
def mouseDoubleClickEvent(self, event):
320332
x, y = self.mouseEventCoords(event.pos())
321-
button = self.buttond.get(event.button())
333+
button = self.buttond.get(_to_int(event.button()))
322334
if button is not None:
323335
FigureCanvasBase.button_press_event(self, x, y,
324336
button, dblclick=True,
@@ -330,7 +342,7 @@ def mouseMoveEvent(self, event):
330342

331343
def mouseReleaseEvent(self, event):
332344
x, y = self.mouseEventCoords(event)
333-
button = self.buttond.get(event.button())
345+
button = self.buttond.get(_to_int(event.button()))
334346
if button is not None:
335347
FigureCanvasBase.button_release_event(self, x, y, button,
336348
guiEvent=event)
@@ -368,6 +380,9 @@ def keyReleaseEvent(self, event):
368380
FigureCanvasBase.key_release_event(self, key, guiEvent=event)
369381

370382
def resizeEvent(self, event):
383+
frame = sys._getframe()
384+
if frame.f_code is frame.f_back.f_code: # Prevent PyQt6 recursion.
385+
return
371386
w = event.size().width() * self._dpi_ratio
372387
h = event.size().height() * self._dpi_ratio
373388
dpival = self.figure.dpi
@@ -388,7 +403,7 @@ def minumumSizeHint(self):
388403

389404
def _get_key(self, event):
390405
event_key = event.key()
391-
event_mods = int(event.modifiers()) # actually a bitmask
406+
event_mods = _to_int(event.modifiers()) # actually a bitmask
392407

393408
# get names of the pressed modifier keys
394409
# 'control' is named 'control' when a standalone key, but 'ctrl' when a
@@ -433,7 +448,7 @@ def start_event_loop(self, timeout=0):
433448
if timeout > 0:
434449
timer = QtCore.QTimer.singleShot(int(timeout * 1000),
435450
event_loop.quit)
436-
event_loop.exec_()
451+
event_loop.exec()
437452

438453
def stop_event_loop(self, event=None):
439454
# docstring inherited
@@ -575,7 +590,7 @@ def __init__(self, canvas, num):
575590
# StrongFocus accepts both tab and click to focus and will enable the
576591
# canvas to process event without clicking.
577592
# https://doc.qt.io/qt-5/qt.html#FocusPolicy-enum
578-
self.canvas.setFocusPolicy(QtCore.Qt.StrongFocus)
593+
self.canvas.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
579594
self.canvas.setFocus()
580595

581596
self.window.raise_()
@@ -654,8 +669,8 @@ class NavigationToolbar2QT(NavigationToolbar2, QtWidgets.QToolBar):
654669
def __init__(self, canvas, parent, coordinates=True):
655670
"""coordinates: should we show the coordinates on the right?"""
656671
QtWidgets.QToolBar.__init__(self, parent)
657-
self.setAllowedAreas(
658-
QtCore.Qt.TopToolBarArea | QtCore.Qt.BottomToolBarArea)
672+
self.setAllowedAreas( # Qt::TopToolBarArea | Qt::BottomToolBarArea
673+
Qt.ToolBarAreas(0x4 | 0x8))
659674

660675
self.coordinates = coordinates
661676
self._actions = {} # mapping of toolitem method names to QActions.
@@ -677,11 +692,11 @@ def __init__(self, canvas, parent, coordinates=True):
677692
# will resize this label instead of the buttons.
678693
if self.coordinates:
679694
self.locLabel = QtWidgets.QLabel("", self)
680-
self.locLabel.setAlignment(
681-
QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
695+
self.locLabel.setAlignment( # Qt::AlignRight | Qt::AlignVCenter
696+
Qt.Alignment(0x02 | 0x80))
682697
self.locLabel.setSizePolicy(
683-
QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding,
684-
QtWidgets.QSizePolicy.Ignored))
698+
QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding,
699+
QtWidgets.QSizePolicy.Policy.Ignored))
685700
labelAction = self.addWidget(self.locLabel)
686701
labelAction.setVisible(True)
687702

@@ -715,7 +730,7 @@ def _icon(self, name):
715730
if self.palette().color(self.backgroundRole()).value() < 128:
716731
icon_color = self.palette().color(self.foregroundRole())
717732
mask = pm.createMaskFromColor(QtGui.QColor('black'),
718-
QtCore.Qt.MaskOutColor)
733+
Qt.MaskMode.MaskOutColor)
719734
pm.fill(icon_color)
720735
pm.setMask(mask)
721736
return QtGui.QIcon(pm)
@@ -785,7 +800,7 @@ def configure_subplots(self):
785800
image = str(cbook._get_data_path('images/matplotlib.png'))
786801
dia = SubplotToolQt(self.canvas.figure, self.canvas.parent())
787802
dia.setWindowIcon(QtGui.QIcon(image))
788-
dia.exec_()
803+
dia.exec()
789804

790805
def save_figure(self, *args):
791806
filetypes = self.canvas.get_supported_filetypes_grouped()
@@ -874,7 +889,7 @@ def _export_values(self):
874889
QtGui.QFontMetrics(text.document().defaultFont())
875890
.size(0, text.toPlainText()).height() + 20)
876891
text.setMaximumSize(size)
877-
dialog.exec_()
892+
dialog.exec()
878893

879894
def _on_value_changed(self):
880895
self._figure.subplots_adjust(**{attr: self._widgets[attr].value()
@@ -899,14 +914,14 @@ class ToolbarQt(ToolContainerBase, QtWidgets.QToolBar):
899914
def __init__(self, toolmanager, parent):
900915
ToolContainerBase.__init__(self, toolmanager)
901916
QtWidgets.QToolBar.__init__(self, parent)
902-
self.setAllowedAreas(
903-
QtCore.Qt.TopToolBarArea | QtCore.Qt.BottomToolBarArea)
917+
self.setAllowedAreas( # Qt::TopToolBarArea | Qt::BottomToolBarArea
918+
Qt.ToolBarAreas(0x4 | 0x8))
904919
message_label = QtWidgets.QLabel("")
905-
message_label.setAlignment(
906-
QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
920+
message_label.setAlignment( # Qt::AlignRight | Qt::AlignVCenter
921+
Qt.Alignment(0x02 | 0x80))
907922
message_label.setSizePolicy(
908-
QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding,
909-
QtWidgets.QSizePolicy.Ignored))
923+
QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding,
924+
QtWidgets.QSizePolicy.Policy.Ignored))
910925
self._message_action = self.addWidget(message_label)
911926
self._toolitems = {}
912927
self._groups = {}
@@ -1031,7 +1046,7 @@ def mainloop():
10311046
if is_python_signal_handler:
10321047
signal.signal(signal.SIGINT, signal.SIG_DFL)
10331048
try:
1034-
qApp.exec_()
1049+
qApp.exec()
10351050
finally:
10361051
# reset the SIGINT exception handler
10371052
if is_python_signal_handler:

lib/matplotlib/backends/backend_qt5agg.py

+9-3
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,20 @@ def paintEvent(self, event):
5959
# clear the widget canvas
6060
painter.eraseRect(rect)
6161

62-
qimage = QtGui.QImage(buf, buf.shape[1], buf.shape[0],
63-
QtGui.QImage.Format_ARGB32_Premultiplied)
62+
if QT_API == "PyQt6":
63+
import sip
64+
buf_ptr = sip.voidptr(buf)
65+
else:
66+
buf_ptr = buf
67+
qimage = QtGui.QImage(
68+
buf_ptr, buf.shape[1], buf.shape[0],
69+
QtGui.QImage.Format.Format_ARGB32_Premultiplied)
6470
_setDevicePixelRatio(qimage, self._dpi_ratio)
6571
# set origin using original QT coordinates
6672
origin = QtCore.QPoint(rect.left(), rect.top())
6773
painter.drawImage(origin, qimage)
6874
# Adjust the buf reference count to work around a memory
69-
# leak bug in QImage under PySide on Python 3.
75+
# leak bug in QImage under PySide.
7076
if QT_API in ('PySide', 'PySide2'):
7177
ctypes.c_long.from_address(id(buf)).value = 1
7278

lib/matplotlib/backends/backend_qt5cairo.py

+9-4
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,16 @@ def paintEvent(self, event):
2626
self._renderer.set_width_height(width, height)
2727
self.figure.draw(self._renderer)
2828
buf = self._renderer.gc.ctx.get_target().get_data()
29-
qimage = QtGui.QImage(buf, width, height,
30-
QtGui.QImage.Format_ARGB32_Premultiplied)
29+
if QT_API == "PyQt6":
30+
import sip
31+
buf_ptr = sip.voidptr(buf)
32+
else:
33+
buf_ptr = buf
34+
qimage = QtGui.QImage(buf_ptr, width, height,
35+
QtGui.QImage.Format.Format_ARGB32_Premultiplied)
3136
# Adjust the buf reference count to work around a memory leak bug in
32-
# QImage under PySide on Python 3.
33-
if QT_API == 'PySide':
37+
# QImage under PySide.
38+
if QT_API in ('PySide', 'PySide2'):
3439
ctypes.c_long.from_address(id(buf)).value = 1
3540
_setDevicePixelRatio(qimage, dpi_ratio)
3641
painter = QtGui.QPainter(self)

0 commit comments

Comments
 (0)