diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 28398cd1973e..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,17 +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 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/doc/devel/dependencies.rst b/doc/devel/dependencies.rst index 32e0211e6ec6..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. -* PyQt5_ or PySide2_: for the Qt5-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 @@ -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..dea9cac47f87 --- /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.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"), + (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.MouseButton"), 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.ToolBarArea").TopToolBarArea + | _enum("QtCore.Qt.ToolBarArea").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.AlignmentFlag").AlignRight + | _enum("QtCore.Qt.AlignmentFlag").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.ToolBarArea").TopToolBarArea + | _enum("QtCore.Qt.ToolBarArea").TopToolBarArea) + message_label = QtWidgets.QLabel("") + message_label.setAlignment( + _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, + )) + 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 f8df0a96240f..a6e368ea384d 100644 --- a/lib/matplotlib/backends/backend_qt5.py +++ b/lib/matplotlib/backends/backend_qt5.py @@ -1,983 +1,18 @@ -import functools -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, - _devicePixelRatioF, _isdeleted, _setDevicePixelRatio, +from .backend_qt import ( + backend_version, SPECIAL_KEYS, + # These are deprecated. + SUPER, ALT, CTRL, SHIFT, MODIFIER_KEYS as _MODIFIER_KEYS, + # Public API + cursord, _create_qApp, _BackendQT, TimerQT, MainWindow, FigureCanvasQT, + FigureManagerQT, ToolbarQt, NavigationToolbar2QT, SubplotToolQt, + SaveFigureQt, ConfigureSubplotsQt, SetCursorQt, RubberbandQt, + HelpQt, ToolCopyToClipboardQT, + # internal re-exports + FigureCanvasBase, FigureManagerBase, MouseButton, NavigationToolbar2, + TimerBase, ToolContainerBase, figureoptions, Gcf ) -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', - }) -# Define which modifier keys are collected on keyboard events. -# Elements are (Modifier Flag, 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), -] -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, -} - - -# 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: # Attribute only exists for Qt>=5.6. - pass - try: - QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy( - QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough) - except AttributeError: # Added in 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) - except AttributeError: - pass - - -def _allow_super_init(__init__): - """ - Decorator for ``__init__`` to allow ``super().__init__`` on PySide2. - """ - - if QT_API == "PyQt5": - - return __init__ - - else: - # To work around lack of cooperative inheritance in PySide2, when - # calling FigureCanvasQT.__init__, we temporarily patch - # QWidget.__init__ by a cooperative version, that first calls - # QWidget.__init__ with no additional arguments, and then finds the - # next class in the MRO with an __init__ that does support cooperative - # inheritance (i.e., not defined by the PySide2, sip or Shiboken - # packages), and manually call its `__init__`, once again passing the - # additional arguments. - - qwidget_init = QtWidgets.QWidget.__init__ - - def cooperative_qwidget_init(self, *args, **kwargs): - qwidget_init(self) - mro = type(self).__mro__ - next_coop_init = next( - cls for cls in mro[mro.index(QtWidgets.QWidget) + 1:] - if cls.__module__.split(".")[0] not in [ - "sip", "PySide2", "Shiboken"]) - next_coop_init.__init__(self, *args, **kwargs) - - @functools.wraps(__init__) - def wrapper(self, *args, **kwargs): - with cbook._setattr_cm(QtWidgets.QWidget, - __init__=cooperative_qwidget_init): - __init__(self, *args, **kwargs) - - return wrapper - - -class TimerQT(TimerBase): - """Subclass of `.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 - - # map Qt button codes to MouseEvent's ones: - buttond = {QtCore.Qt.LeftButton: MouseButton.LEFT, - QtCore.Qt.MidButton: MouseButton.MIDDLE, - QtCore.Qt.RightButton: MouseButton.RIGHT, - QtCore.Qt.XButton1: MouseButton.BACK, - QtCore.Qt.XButton2: MouseButton.FORWARD, - } - - @_allow_super_init - def __init__(self, figure=None): - _create_qApp() - super().__init__(figure=figure) - - self._draw_pending = False - self._is_drawing = False - self._draw_rect_callback = lambda painter: None - - self.setAttribute(QtCore.Qt.WA_OpaquePaintEvent) - self.setMouseTracking(True) - self.resize(*self.get_width_height()) - - palette = QtGui.QPalette(QtCore.Qt.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(event.pos()) - FigureCanvasBase.enter_notify_event(self, guiEvent=event, xy=(x, y)) - - def leaveEvent(self, event): - QtWidgets.QApplication.restoreOverrideCursor() - FigureCanvasBase.leave_notify_event(self, guiEvent=event) - - def mouseEventCoords(self, pos): - """ - Calculate mouse coordinates in physical pixels. - - Qt5 use logical pixels, but the figure is scaled to physical - pixels for rendering. Transform to physical pixels so that - all of the down-stream transforms work as expected. - - Also, the origin is different and needs to be corrected. - """ - 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(event.pos()) - button = self.buttond.get(event.button()) - if button is not None: - FigureCanvasBase.button_press_event(self, x, y, button, - guiEvent=event) - - def mouseDoubleClickEvent(self, event): - x, y = self.mouseEventCoords(event.pos()) - button = self.buttond.get(event.button()) - if button is not None: - FigureCanvasBase.button_press_event(self, x, y, - button, dblclick=True, - guiEvent=event) - - def mouseMoveEvent(self, event): - x, y = self.mouseEventCoords(event) - FigureCanvasBase.motion_notify_event(self, x, y, guiEvent=event) - - def mouseReleaseEvent(self, event): - x, y = self.mouseEventCoords(event) - button = self.buttond.get(event.button()) - if button is not None: - FigureCanvasBase.button_release_event(self, x, y, button, - guiEvent=event) - - def wheelEvent(self, event): - x, y = self.mouseEventCoords(event) - # from QWheelEvent::delta doc - if event.pixelDelta().x() == 0 and event.pixelDelta().y() == 0: - steps = event.angleDelta().y() / 120 - else: - steps = event.pixelDelta().y() - if steps: - FigureCanvasBase.scroll_event( - self, x, y, steps, guiEvent=event) - - 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): - 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 = 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) - event_loop.exec_() - - def stop_event_loop(self, event=None): - # docstring inherited - if hasattr(self, "_event_loop"): - self._event_loop.quit() - - def draw(self): - """Render the figure, and queue a request for a Qt draw.""" - # The renderer draw is done here; delaying causes problems with code - # that uses the result of the draw() to update plot elements. - if self._is_drawing: - return - with cbook._setattr_cm(self, _is_drawing=True): - super().draw() - self.update() - - def draw_idle(self): - """Queue redraw of the Agg buffer and request Qt paintEvent.""" - # The Agg draw needs to be handled by the same thread Matplotlib - # modifies the scene graph from. Post Agg draw request to the - # current event loop in order to ensure thread affinity and to - # accumulate multiple draw requests from event handling. - # TODO: queued signal connection might be safer than singleShot - if not (getattr(self, '_draw_pending', False) or - getattr(self, '_is_drawing', False)): - self._draw_pending = True - QtCore.QTimer.singleShot(0, self._draw_idle) - - def 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(QtCore.Qt.black, 1 / self.device_pixel_ratio) - pen.setDashPattern([3, 3]) - for color, offset in [ - (QtCore.Qt.black, 0), (QtCore.Qt.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(QtCore.Qt.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( - QtCore.Qt.TopToolBarArea | QtCore.Qt.BottomToolBarArea) - - 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( - QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) - self.locLabel.setSizePolicy( - QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.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'), - QtCore.Qt.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( - QtCore.Qt.TopToolBarArea | QtCore.Qt.BottomToolBarArea) - message_label = QtWidgets.QLabel("") - message_label.setAlignment( - QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) - message_label.setSizePolicy( - QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.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: - qApp.exec_() - 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 3c5de72f7697..d176fbe82bfb 100644 --- a/lib/matplotlib/backends/backend_qt5agg.py +++ b/lib/matplotlib/backends/backend_qt5agg.py @@ -1,84 +1,13 @@ """ -Render to qt from agg. +Render to qt from agg """ -import ctypes +from .backend_qtagg import ( + _BackendQTAgg, FigureCanvasQTAgg, FigureManagerQT, NavigationToolbar2QT, + backend_version, FigureCanvasAgg, FigureCanvasQT +) -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, _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) - - qimage = QtGui.QImage(buf, buf.shape[1], buf.shape[0], - QtGui.QImage.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. - 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 e15e0d858ad8..51eae512c654 100644 --- a/lib/matplotlib/backends/backend_qt5cairo.py +++ b/lib/matplotlib/backends/backend_qt5cairo.py @@ -1,44 +1,10 @@ -import ctypes +from .backend_qtcairo import ( + _BackendQTCairo, FigureCanvasQTCairo, + FigureCanvasCairo, FigureCanvasQT, + RendererCairo +) -from .backend_cairo import cairo, FigureCanvasCairo, RendererCairo -from .backend_qt5 import QtCore, QtGui, _BackendQT5, FigureCanvasQT -from .qt_compat import QT_API, _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() - qimage = QtGui.QImage(buf, width, height, - QtGui.QImage.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': - 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_compat.py b/lib/matplotlib/backends/qt_compat.py index f31db2e98fc6..d90d63fe3473 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,35 @@ 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 + "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_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 +101,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 +123,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 +141,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..9ca8963e2b23 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.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(QtWidgets.QDialogButtonBox.Apply) + apply_btn = bbox.addButton( + _enum("QtWidgets.QDialogButtonBox.StandardButton").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", "Apply"]: + btn = self.bbox.button( + getattr(_enum("QtWidgets.QDialogButtonBox.StandardButton"), + 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/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/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"] diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index 95ef41d97978..0d3afc8173c4 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -1,20 +1,27 @@ 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: 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 @@ -26,13 +33,8 @@ def qt_core(request): return QtCore -@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)), -]) -def test_fig_close(backend): +@pytest.mark.backend('QtAgg', skip_on_importerror=True) +def test_fig_close(): # save the state of Gcf.figs init_figs = copy.copy(Gcf.figs) @@ -48,7 +50,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 +81,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 +130,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): """ @@ -136,13 +141,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.KeyboardModifier").NoModifier for mod in qt_mods: - qt_mod |= getattr(qt_core.Qt, mod) + qt_mod |= getattr(_enum("QtCore.Qt.KeyboardModifier"), 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): @@ -153,14 +159,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 @@ -226,28 +232,24 @@ 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.backend_qt5.SubplotToolQt.exec_", - lambda self: None): + 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]) 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() -@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 = [ @@ -255,13 +257,11 @@ 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() -@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() @@ -281,9 +281,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 @@ -315,3 +315,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) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index dc72b68468b8..d524306478ad 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,18 @@ 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)) -@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 +265,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 +293,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/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) 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/