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/ pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy