diff --git a/hello.gif b/hello_qml.gif similarity index 100% rename from hello.gif rename to hello_qml.gif diff --git a/hello_qwidget.gif b/hello_qwidget.gif new file mode 100644 index 0000000..819d638 Binary files /dev/null and b/hello_qwidget.gif differ diff --git a/readme.md b/readme.md index ab99702..170152f 100644 --- a/readme.md +++ b/readme.md @@ -1,18 +1,39 @@ -A matplotlib backend always base on the latest QT for Python, no compatible will be care +A matplotlib mini backend always base on the latest QT for Python, no compatible will be care -Focus on Qt 6 for Python and QML way for the first release +Focus on Qt 6 (will always point to the latest) for Python and QML/QWidget as main user case, only desktop application will be care -The first version mainly base on https://github.com/jmitrevs/matplotlib_backend_qtquick +Mainly base on: -The module can run itself which will run a demo application. + 1. https://github.com/jmitrevs/matplotlib_backend_qtquick + 2. https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/backends/backend_qt5.py + 3. https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/backends/backend_qt5agg.py + +The module can run as qml demo application itself, like: + +```bash + python -m matplotlibqml +``` + +A standalone classic QWidget sample(pure QWidget) is provided too, like: + +```bash + python -m matplotlibqml.widgetdemo +``` # 你好 -![](https://raw.githubusercontent.com/medlab/matplotlibqml/main/hello.gif) +![](https://raw.githubusercontent.com/medlab/matplotlibqml/main/hello_qml.gif) +![](https://raw.githubusercontent.com/medlab/matplotlibqml/main/hello_qwidget.gif) + +Why +================= + QT keep improving and changing, and compatibility will kill flexibility, especially when upstream not so stable Credit: ================= 1. https://github.com/fcollonval/matplotlib_qtquick_playground 2. https://github.com/jmitrevs/matplotlib_backend_qtquick - 3. Some weekend play for fun self Python project \ No newline at end of file + 3. https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/backends/backend_qt5.py + 4. https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/backends/backend_qt5agg.py + 5. Some weekend play for fun self Python project \ No newline at end of file diff --git a/setup.py b/setup.py index aa60e57..56a5814 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( name='matplotlibqml', - version='0.91', + version='0.92', description='Matplotlib Mini QML Backend', long_description=open('readme.md').read(), long_description_content_type='text/markdown', diff --git a/src/matplotlibqml/matplotlibqml.py b/src/matplotlibqml/matplotlibqml.py index e65269f..0a9a763 100644 --- a/src/matplotlibqml/matplotlibqml.py +++ b/src/matplotlibqml/matplotlibqml.py @@ -1,21 +1,22 @@ import logging +import operator import os import sys +import time import traceback from pathlib import Path import matplotlib import numpy as np -from matplotlib import cbook +from PySide6 import QtCore, QtGui, QtQuick, QtWidgets +from PySide6.QtCore import Qt, qInstallMessageHandler, QMessageLogContext, QtMsgType +from matplotlib import cbook from matplotlib.backend_bases import FigureCanvasBase, NavigationToolbar2, MouseButton, TimerBase from matplotlib.backend_tools import cursors from matplotlib.backends.backend_agg import FigureCanvasAgg from matplotlib.figure import Figure -from PySide6 import QtCore, QtGui, QtQuick, QtWidgets - -from PySide6.QtCore import Qt, qInstallMessageHandler, QMessageLogContext, QtMsgType - +from matplotlib.transforms import Bbox class TimerQT(TimerBase): """Subclass of `.TimerBase` using QTimer events.""" @@ -104,6 +105,256 @@ def _timer_stop(self): cursors.WAIT:QtCore.Qt.CursorShape.WaitCursor, } +# map Qt button codes to MouseEvent's ones: +buttond = {QtCore.Qt.LeftButton: MouseButton.LEFT, + QtCore.Qt.MiddleButton: MouseButton.MIDDLE, + QtCore.Qt.RightButton: MouseButton.RIGHT, + QtCore.Qt.XButton1: MouseButton.BACK, + QtCore.Qt.XButton2: MouseButton.FORWARD, + } + +_getSaveFileName = QtWidgets.QFileDialog.getSaveFileName + + +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. + + This can be replaced by the direct call when we require Qt>=5.6. + """ + try: + # Not available on Qt<5.6 + return obj.devicePixelRatioF() or 1 + except AttributeError: + pass + try: + # Not available on Qt4 or some older Qt5. + # self.devicePixelRatio() returns 0 in rare cases + return obj.devicePixelRatio() or 1 + except AttributeError: + return 1 + + +def _setDevicePixelRatio(obj, val): + """ + Call obj.setDevicePixelRatio(val) with graceful fallback for older Qt. + + This can be replaced by the direct call when we require Qt>=5.6. + """ + if hasattr(obj, 'setDevicePixelRatio'): + # Not available on Qt4 or some older Qt5. + obj.setDevicePixelRatio(val) + + +class MatplotlibIconProvider(QtQuick.QQuickImageProvider): + """ This class provide the matplotlib icons for the navigation toolbar. + """ + + def __init__(self, img_type=QtQuick.QQuickImageProvider.Image): + self.basedir = os.path.join(matplotlib.rcParams['datapath'], 'images') + QtQuick.QQuickImageProvider.__init__(self, img_type) + + def requestImage(self, ids, size, reqSize): + img = QtGui.QImage(os.path.join(self.basedir, ids + '.png')) + size.setWidth(img.width()) + size.setHeight(img.height()) + return img + + +class NavigationToolbar2QtQuick(QtCore.QObject, NavigationToolbar2): + """ NavigationToolbar2 customized for QtQuick + """ + + messageChanged = QtCore.Signal(str) + + leftChanged = QtCore.Signal() + rightChanged = QtCore.Signal() + topChanged = QtCore.Signal() + bottomChanged = QtCore.Signal() + wspaceChanged = QtCore.Signal() + hspaceChanged = QtCore.Signal() + + def __init__(self, canvas, parent=None): + + # I think this is needed due to a bug in PySide2 + # if QT_API == QT_API_PYSIDE2: + # QtCore.QObject.__init__(self, parent) + # NavigationToolbar2.__init__(self, canvas) + # else: + # super().__init__(canvas=canvas, parent=parent) + QtCore.QObject.__init__(self, parent) + NavigationToolbar2.__init__(self, canvas) + + self._message = "" + + # + # Store margin + # + self._defaults = {} + for attr in ('left', 'bottom', 'right', 'top', 'wspace', 'hspace', ): + val = getattr(self.canvas.figure.subplotpars, attr) + self._defaults[attr] = val + setattr(self, attr, val) + + def _init_toolbar(self): + """ don't actually build the widgets here, build them in QML + """ + pass + + # Define a few properties. + def getMessage(self): + return self._message + + def setMessage(self, msg): + if msg != self._message: + self._message = msg + self.messageChanged.emit(msg) + + message = QtCore.Property(str, getMessage, setMessage, + notify=messageChanged) + + def getLeft(self): + return self.canvas.figure.subplotpars.left + + def setLeft(self, value): + if value != self.canvas.figure.subplotpars.left: + self.canvas.figure.subplots_adjust(left=value) + self.leftChanged.emit() + + self.canvas.draw_idle() + + left = QtCore.Property(float, getLeft, setLeft, notify=leftChanged) + + def getRight(self): + return self.canvas.figure.subplotpars.right + + def setRight(self, value): + if value != self.canvas.figure.subplotpars.right: + self.canvas.figure.subplots_adjust(right=value) + self.rightChanged.emit() + + self.canvas.draw_idle() + + right = QtCore.Property(float, getRight, setRight, notify=rightChanged) + + def getTop(self): + return self.canvas.figure.subplotpars.top + + def setTop(self, value): + if value != self.canvas.figure.subplotpars.top: + self.canvas.figure.subplots_adjust(top=value) + self.topChanged.emit() + + self.canvas.draw_idle() + + top = QtCore.Property(float, getTop, setTop, notify=topChanged) + + def getBottom(self): + return self.canvas.figure.subplotpars.bottom + + def setBottom(self, value): + if value != self.canvas.figure.subplotpars.bottom: + self.canvas.figure.subplots_adjust(bottom=value) + self.bottomChanged.emit() + + self.canvas.draw_idle() + + bottom = QtCore.Property(float, getBottom, setBottom, notify=bottomChanged) + + def getHspace(self): + return self.canvas.figure.subplotpars.hspace + + def setHspace(self, value): + if value != self.canvas.figure.subplotpars.hspace: + self.canvas.figure.subplots_adjust(hspace=value) + self.hspaceChanged.emit() + + self.canvas.draw_idle() + + hspace = QtCore.Property(float, getHspace, setHspace, notify=hspaceChanged) + + def getWspace(self): + return self.canvas.figure.subplotpars.wspace + + def setWspace(self, value): + if value != self.canvas.figure.subplotpars.wspace: + self.canvas.figure.subplots_adjust(wspace=value) + self.wspaceChanged.emit() + + self.canvas.draw_idle() + + wspace = QtCore.Property(float, getWspace, setWspace, notify=wspaceChanged) + + def set_history_buttons(self): + """Enable or disable back/forward button""" + pass + + def set_cursor(self, cursor): + """ + Set the current cursor to one of the :class:`Cursors` + enums values + """ + self.canvas.setCursor(cursord[cursor]) + + def draw_with_locators_update(self): + """Redraw the canvases, update the locators""" + for a in self.canvas.figure.get_axes(): + xaxis = getattr(a, 'xaxis', None) + yaxis = getattr(a, 'yaxis', None) + locators = [] + if xaxis is not None: + locators.append(xaxis.get_major_locator()) + locators.append(xaxis.get_minor_locator()) + if yaxis is not None: + locators.append(yaxis.get_major_locator()) + locators.append(yaxis.get_minor_locator()) + + for loc in locators: + loc.refresh() + self.canvas.draw_idle() + + def draw_rubberband(self, event, x0, y0, x1, y1): + """Draw a rectangle rubberband to indicate zoom limits""" + height = self.canvas.figure.bbox.height + y1 = height - y1 + y0 = height - y0 + + w = abs(x1 - x0) + h = abs(y1 - y0) + + rect = [int(val)for val in (min(x0, x1), min(y0, y1), w, h)] + self.canvas.drawRectangle(rect) + + def remove_rubberband(self): + """Remove the rubberband""" + self.canvas.drawRectangle(None) + + def tight_layout(self): + self.canvas.figure.tight_layout() + # self._setSliderPositions() + self.canvas.draw_idle() + + def reset_margin(self): + self.canvas.figure.subplots_adjust(**self._defaults) + # self._setSliderPositions() + self.canvas.draw_idle() + + def print_figure(self, fname, *args, **kwargs): + if fname: + fname = QtCore.QUrl(fname).toLocalFile() + # save dir for next time + matplotlib.rcParams['savefig.directory'] = os.path.dirname(fname) + NavigationToolbar2.print_figure(self, fname, *args, **kwargs) + self.canvas.draw_idle() + + def save_figure(self, *args): + raise NotImplementedError("save_figure is not yet implemented") + class FigureCanvasQtQuick(QtQuick.QQuickPaintedItem, FigureCanvasBase): """ This class creates a QtQuick Item encapsulating a Matplotlib Figure and all the functions to interact with the 'standard' @@ -112,13 +363,7 @@ class FigureCanvasQtQuick(QtQuick.QQuickPaintedItem, FigureCanvasBase): dpi_ratio_changed = QtCore.Signal() - # map Qt button codes to MouseEvent's ones: - buttond = {QtCore.Qt.LeftButton: MouseButton.LEFT, - QtCore.Qt.MiddleButton: MouseButton.MIDDLE, - QtCore.Qt.RightButton: MouseButton.RIGHT, - QtCore.Qt.XButton1: MouseButton.BACK, - QtCore.Qt.XButton2: MouseButton.FORWARD, - } + def __init__(self, figure=None, parent=None): if figure is None: @@ -307,26 +552,27 @@ def mouseMoveEvent(self, event): def mousePressEvent(self, event): x, y = self.mouseEventCoords(event.pos()) - button = self.buttond.get(event.button()) + button =buttond.get(event.button()) if button is not None: FigureCanvasBase.button_press_event(self, x, y, button, guiEvent=event) def mouseReleaseEvent(self, event): x, y = self.mouseEventCoords(event.pos()) - button = self.buttond.get(event.button()) + button =buttond.get(event.button()) if button is not None: FigureCanvasBase.button_release_event(self, x, y, button, guiEvent=event) def mouseDoubleClickEvent(self, event): x, y = self.mouseEventCoords(event.pos()) - button = self.buttond.get(event.button()) + button =buttond.get(event.button()) if button is not None: FigureCanvasBase.button_press_event(self, x, y, button, dblclick=True, guiEvent=event) + #TODO def wheelEvent(self, event): x, y = self.mouseEventCoords(event.pos()) # from QWheelEvent::delta doc @@ -406,231 +652,25 @@ def flush_events(self): global qApp qApp.processEvents() - -class MatplotlibIconProvider(QtQuick.QQuickImageProvider): - """ This class provide the matplotlib icons for the navigation toolbar. +class FigureCanvasQtQuickAgg(FigureCanvasAgg, FigureCanvasQtQuick): + """ This class customizes the FigureCanvasQtQuick for Agg """ + def __init__(self, figure=None, parent=None): + super().__init__(figure=figure, parent=parent) + self.blitbox = None - def __init__(self, img_type=QtQuick.QQuickImageProvider.Image): - self.basedir = os.path.join(matplotlib.rcParams['datapath'], 'images') - QtQuick.QQuickImageProvider.__init__(self, img_type) + def paint(self, p): + """ + 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. - def requestImage(self, ids, size, reqSize): - img = QtGui.QImage(os.path.join(self.basedir, ids + '.png')) - size.setWidth(img.width()) - size.setHeight(img.height()) - return img - - -class NavigationToolbar2QtQuick(QtCore.QObject, NavigationToolbar2): - """ NavigationToolbar2 customized for QtQuick - """ - - messageChanged = QtCore.Signal(str) - - leftChanged = QtCore.Signal() - rightChanged = QtCore.Signal() - topChanged = QtCore.Signal() - bottomChanged = QtCore.Signal() - wspaceChanged = QtCore.Signal() - hspaceChanged = QtCore.Signal() - - def __init__(self, canvas, parent=None): - - # I think this is needed due to a bug in PySide2 - # if QT_API == QT_API_PYSIDE2: - # QtCore.QObject.__init__(self, parent) - # NavigationToolbar2.__init__(self, canvas) - # else: - # super().__init__(canvas=canvas, parent=parent) - QtCore.QObject.__init__(self, parent) - NavigationToolbar2.__init__(self, canvas) - - self._message = "" - - # - # Store margin - # - self._defaults = {} - for attr in ('left', 'bottom', 'right', 'top', 'wspace', 'hspace', ): - val = getattr(self.canvas.figure.subplotpars, attr) - self._defaults[attr] = val - setattr(self, attr, val) - - def _init_toolbar(self): - """ don't actually build the widgets here, build them in QML - """ - pass - - # Define a few properties. - def getMessage(self): - return self._message - - def setMessage(self, msg): - if msg != self._message: - self._message = msg - self.messageChanged.emit(msg) - - message = QtCore.Property(str, getMessage, setMessage, - notify=messageChanged) - - def getLeft(self): - return self.canvas.figure.subplotpars.left - - def setLeft(self, value): - if value != self.canvas.figure.subplotpars.left: - self.canvas.figure.subplots_adjust(left=value) - self.leftChanged.emit() - - self.canvas.draw_idle() - - left = QtCore.Property(float, getLeft, setLeft, notify=leftChanged) - - def getRight(self): - return self.canvas.figure.subplotpars.right - - def setRight(self, value): - if value != self.canvas.figure.subplotpars.right: - self.canvas.figure.subplots_adjust(right=value) - self.rightChanged.emit() - - self.canvas.draw_idle() - - right = QtCore.Property(float, getRight, setRight, notify=rightChanged) - - def getTop(self): - return self.canvas.figure.subplotpars.top - - def setTop(self, value): - if value != self.canvas.figure.subplotpars.top: - self.canvas.figure.subplots_adjust(top=value) - self.topChanged.emit() - - self.canvas.draw_idle() - - top = QtCore.Property(float, getTop, setTop, notify=topChanged) - - def getBottom(self): - return self.canvas.figure.subplotpars.bottom - - def setBottom(self, value): - if value != self.canvas.figure.subplotpars.bottom: - self.canvas.figure.subplots_adjust(bottom=value) - self.bottomChanged.emit() - - self.canvas.draw_idle() - - bottom = QtCore.Property(float, getBottom, setBottom, notify=bottomChanged) - - def getHspace(self): - return self.canvas.figure.subplotpars.hspace - - def setHspace(self, value): - if value != self.canvas.figure.subplotpars.hspace: - self.canvas.figure.subplots_adjust(hspace=value) - self.hspaceChanged.emit() - - self.canvas.draw_idle() - - hspace = QtCore.Property(float, getHspace, setHspace, notify=hspaceChanged) - - def getWspace(self): - return self.canvas.figure.subplotpars.wspace - - def setWspace(self, value): - if value != self.canvas.figure.subplotpars.wspace: - self.canvas.figure.subplots_adjust(wspace=value) - self.wspaceChanged.emit() - - self.canvas.draw_idle() - - wspace = QtCore.Property(float, getWspace, setWspace, notify=wspaceChanged) - - def set_history_buttons(self): - """Enable or disable back/forward button""" - pass - - def set_cursor(self, cursor): - """ - Set the current cursor to one of the :class:`Cursors` - enums values - """ - self.canvas.setCursor(cursord[cursor]) - - def draw_with_locators_update(self): - """Redraw the canvases, update the locators""" - for a in self.canvas.figure.get_axes(): - xaxis = getattr(a, 'xaxis', None) - yaxis = getattr(a, 'yaxis', None) - locators = [] - if xaxis is not None: - locators.append(xaxis.get_major_locator()) - locators.append(xaxis.get_minor_locator()) - if yaxis is not None: - locators.append(yaxis.get_major_locator()) - locators.append(yaxis.get_minor_locator()) - - for loc in locators: - loc.refresh() - self.canvas.draw_idle() - - def draw_rubberband(self, event, x0, y0, x1, y1): - """Draw a rectangle rubberband to indicate zoom limits""" - height = self.canvas.figure.bbox.height - y1 = height - y1 - y0 = height - y0 - - w = abs(x1 - x0) - h = abs(y1 - y0) - - rect = [int(val)for val in (min(x0, x1), min(y0, y1), w, h)] - self.canvas.drawRectangle(rect) - - def remove_rubberband(self): - """Remove the rubberband""" - self.canvas.drawRectangle(None) - - def tight_layout(self): - self.canvas.figure.tight_layout() - # self._setSliderPositions() - self.canvas.draw_idle() - - def reset_margin(self): - self.canvas.figure.subplots_adjust(**self._defaults) - # self._setSliderPositions() - self.canvas.draw_idle() - - def print_figure(self, fname, *args, **kwargs): - if fname: - fname = QtCore.QUrl(fname).toLocalFile() - # save dir for next time - matplotlib.rcParams['savefig.directory'] = os.path.dirname(fname) - NavigationToolbar2.print_figure(self, fname, *args, **kwargs) - self.canvas.draw_idle() - - def save_figure(self, *args): - raise NotImplementedError("save_figure is not yet implemented") - - -class FigureCanvasQtQuickAgg(FigureCanvasAgg, FigureCanvasQtQuick): - """ This class customizes the FigureCanvasQtQuick for Agg - """ - def __init__(self, figure=None, parent=None): - super().__init__(figure=figure, parent=parent) - self.blitbox = None - - def paint(self, p): - """ - 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 + # 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 if self.blitbox is None: # matplotlib is in rgba byte order. QImage wants to put the bytes @@ -708,35 +748,420 @@ def print_figure(self, *args, **kwargs): super().print_figure(*args, **kwargs) self.draw() +#TODO may crash sometime +class FigureCanvasQT(QtWidgets.QWidget, FigureCanvasBase): + required_interactive_framework = "qt" + _timer_cls = TimerQT + + def __init__(self, figure=None, parent=None): + #TODO? how to init QWidget? + #super().__init__(figure=figure) + QtWidgets.QWidget.__init__(self, parent=parent) + FigureCanvasBase.__init__(self, figure=figure) + + + # We don't want to scale up the figure DPI more than once. + # Note, we don't handle a signal for changing DPI yet. + self.figure._original_dpi = self.figure.dpi + self._update_figure_dpi() + # In cases with mixed resolution displays, we need to be careful if the + # dpi_ratio changes - in this case we need to resize the canvas + # accordingly. + self._dpi_ratio_prev = self._dpi_ratio + + self._draw_pending = False + self._is_drawing = False + self._draw_rect_callback = lambda painter: None + + self.setAttribute( + 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_figure_dpi(self): + dpi = self._dpi_ratio * self.figure._original_dpi + self.figure._set_dpi(dpi, forward=False) + + @property + def _dpi_ratio(self): + #return _devicePixelRatioF(self) + return self.devicePixelRatioF() or 1 + + def _update_pixel_ratio(self): + # We need to be careful in cases with mixed resolution displays if + # dpi_ratio changes. + if self._dpi_ratio != self._dpi_ratio_prev: + # We need to update the figure DPI. + self._update_figure_dpi() + self._dpi_ratio_prev = self._dpi_ratio + # 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) + # resizeEvent triggers a paintEvent itself, so we exit this one + # (after making sure that the event is immediately handled). + + 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 get_width_height(self): + w, h = FigureCanvasBase.get_width_height(self) + return int(w / self._dpi_ratio), int(h / self._dpi_ratio) + + def enterEvent(self, event): + try: + x, y = self.mouseEventCoords(self._get_position(event)) + except AttributeError: + # the event from PyQt4 does not include the position + x = y = None + 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") + + + _get_position = operator.methodcaller( + "position") + + def mouseEventCoords(self, pos): + """ + Calculate mouse coordinates in physical pixels. + + Qt 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. + """ + dpi_ratio = self._dpi_ratio + x = pos.x() + # flip y so y=0 is bottom of canvas + y = self.figure.bbox.height / dpi_ratio - pos.y() + return x * dpi_ratio, y * dpi_ratio + + def mousePressEvent(self, event): + x, y = self.mouseEventCoords(self._get_position(event)) + button =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 =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 =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._dpi_ratio + h = event.size().height() * self._dpi_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 + 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) + #qt_compat._exec(event_loop) + 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._dpi_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._dpi_ratio) for pt in rect] + x1 = x0 + w + y1 = y0 + h + def _draw_rect_callback(painter): + pen = QtGui.QPen(QtGui.QColor("black"), 1 / self._dpi_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() + +#TODO may crash sometime +class FigureCanvasQTAgg(FigureCanvasAgg, FigureCanvasQT): + + def __init__(self, figure): + # 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._dpi_ratio + height = rect.height() * self._dpi_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 + ptr = buf + qimage = QtGui.QImage( + ptr, buf.shape[1], buf.shape[0], + QtGui.QImage.Format.Format_ARGB32_Premultiplied) + #_setDevicePixelRatio(qimage, self._dpi_ratio) + qimage.setDevicePixelRatio(self._dpi_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 + #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() + # The first one is a standard name; The second not so FigureCanvas = FigureCanvasQtQuickAgg +class DemoViewModel(QtCore.QObject): + """ A bridge class to interact with the plot in python + """ + coordinatesChanged = QtCore.Signal(str) -if __name__ == "__main__": + def __init__(self, parent=None): + super().__init__(parent) - class DemoViewModel(QtCore.QObject): - """ A bridge class to interact with the plot in python - """ - coordinatesChanged = QtCore.Signal(str) + # The figure and toolbar + self.figure = None + self.toolbar = None - def __init__(self, parent=None): - super().__init__(parent) + # this is used to display the coordinates of the mouse in the window + self._coordinates = "" - # The figure and toolbar - self.figure = None - self.toolbar = None + self.pause=False - # this is used to display the coordinates of the mouse in the window - self._coordinates = "" + def updateWithCanvas(self, canvas, dynamic=False): + """ initialize with the canvas for the figure + """ + self.figure = canvas.figure - def updateWithCanvas(self, canvas): - """ initialize with the canvas for the figure - """ - self.figure = canvas.figure - self.toolbar = NavigationToolbar2QtQuick(canvas=canvas) + self.update_toolbar(canvas) + if not dynamic: # make a small plot self.axes = self.figure.add_subplot(111) self.axes.grid(True) @@ -746,76 +1171,85 @@ def updateWithCanvas(self, canvas): self.axes.plot(x, y) canvas.draw_idle() + else: + self.axes = canvas.figure.subplots() + t = np.linspace(0, 10, 101) + # Set up a Line2D. + self._line, = self.axes.plot(t, np.sin(t + time.time())) + self._timer = canvas.new_timer(50) + self._timer.add_callback(self._update_canvas) + self._timer.start() + # connect for displaying the coordinates + self.figure.canvas.mpl_connect('motion_notify_event', self.on_motion) + + def _update_canvas(self): + if self.pause : + return + t = np.linspace(0, 10, 101) + # Shift the sinusoid as a function of time. + self._line.set_data(t, np.sin(t + time.time())) + self._line.figure.canvas.draw() + + def update_toolbar(self, canvas): + # tips: use platform specific NavigationToolbar2QtQuick if you want to see the rubberband + # self.toolbar = NavigationToolbar2QtQuick(canvas=canvas) + self.toolbar = NavigationToolbar2(canvas=canvas) + + # define the coordinates property + # (I have had problems using the @QtCore.Property directy in the past) + def getCoordinates(self): + return self._coordinates + + def setCoordinates(self, coordinates): + self._coordinates = coordinates + self.coordinatesChanged.emit(self._coordinates) + + coordinates = QtCore.Property(str, getCoordinates, setCoordinates, + notify=coordinatesChanged) + + #TODO from ui or to ui, args? + @QtCore.Slot() + def pauseChanged(self, new_state:bool): + self.pause = new_state + pass - # connect for displaying the coordinates - self.figure.canvas.mpl_connect('motion_notify_event', self.on_motion) - - # define the coordinates property - # (I have had problems using the @QtCore.Property directy in the past) - def getCoordinates(self): - return self._coordinates - - def setCoordinates(self, coordinates): - self._coordinates = coordinates - self.coordinatesChanged.emit(self._coordinates) - - coordinates = QtCore.Property(str, getCoordinates, setCoordinates, - notify=coordinatesChanged) + # The toolbar commands + @QtCore.Slot() + def pan(self, *args): + """Activate the pan tool.""" + self.toolbar.pan(*args) - # The toolbar commands - @QtCore.Slot() - def pan(self, *args): - """Activate the pan tool.""" - self.toolbar.pan(*args) + @QtCore.Slot() + def zoom(self, *args): + """activate zoom tool.""" + self.toolbar.zoom(*args) - @QtCore.Slot() - def zoom(self, *args): - """activate zoom tool.""" - self.toolbar.zoom(*args) + @QtCore.Slot() + def home(self, *args): + self.toolbar.home(*args) - @QtCore.Slot() - def home(self, *args): - self.toolbar.home(*args) + @QtCore.Slot() + def back(self, *args): + self.toolbar.back(*args) - @QtCore.Slot() - def back(self, *args): - self.toolbar.back(*args) + @QtCore.Slot() + def forward(self, *args): + self.toolbar.forward(*args) - @QtCore.Slot() - def forward(self, *args): - self.toolbar.forward(*args) + def on_motion(self, event): + """ + Update the coordinates on the display + """ + if event.inaxes == self.axes: + self.coordinates = f"({event.xdata:.2f}, {event.ydata:.2f})" - def on_motion(self, event): - """ - Update the coordinates on the display - """ - if event.inaxes == self.axes: - self.coordinates = f"({event.xdata:.2f}, {event.ydata:.2f})" +def myMessageOutput(type:QtMsgType, context:QMessageLogContext, msg:str): + logging.info(rf'====> {msg}') + pass +if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") - def myMessageOutput(type:QtMsgType, context:QMessageLogContext, msg:str): - logging.info(rf'====> {msg}') - pass - # QByteArray localMsg = msg.toLocal8Bit(); - # switch (type) { - # case QtDebugMsg: - # fprintf(stderr, "Debug: %s (%s:%u, %s)\n", localMsg.constData(), context.file, context.line, context.function); - # break; - # case QtInfoMsg: - # fprintf(stderr, "Info: %s (%s:%u, %s)\n", localMsg.constData(), context.file, context.line, context.function); - # break; - # case QtWarningMsg: - # fprintf(stderr, "Warning: %s (%s:%u, %s)\n", localMsg.constData(), context.file, context.line, context.function); - # break; - # case QtCriticalMsg: - # fprintf(stderr, "Critical: %s (%s:%u, %s)\n", localMsg.constData(), context.file, context.line, context.function); - # break; - # case QtFatalMsg: - # fprintf(stderr, "Fatal: %s (%s:%u, %s)\n", localMsg.constData(), context.file, context.line, context.function); - # abort(); - # } - qInstallMessageHandler(myMessageOutput) import PySide6 @@ -838,6 +1272,6 @@ def myMessageOutput(type:QtMsgType, context:QMessageLogContext, msg:str): engine.load(QtCore.QUrl.fromLocalFile(str(qmlFile))) win = engine.rootObjects()[0] - vm.updateWithCanvas(win.findChild(QtCore.QObject, "figure")) + vm.updateWithCanvas(win.findChild(QtCore.QObject, "figure"), dynamic=False) # execute and cleanup app.exec_() \ No newline at end of file diff --git a/src/matplotlibqml/widgetdemo.py b/src/matplotlibqml/widgetdemo.py new file mode 100644 index 0000000..a6f4432 --- /dev/null +++ b/src/matplotlibqml/widgetdemo.py @@ -0,0 +1,83 @@ +import logging +import sys +import time + +from PySide6 import QtCore, QtWidgets +import numpy as np +from PySide6.QtCore import qInstallMessageHandler +from matplotlib.figure import Figure + +import faulthandler + +faulthandler.enable() + +from .matplotlibqml import FigureCanvasQTAgg, DemoViewModel, myMessageOutput + + +class ApplicationWindow(QtWidgets.QMainWindow): + def __init__(self, vm: DemoViewModel): + super().__init__() + self.vm=vm + self._main = QtWidgets.QWidget() + self.setCentralWidget(self._main) + layout = QtWidgets.QVBoxLayout(self._main) + + toolbar = QtWidgets.QHBoxLayout() + + home_btn=QtWidgets.QPushButton(text='home') + home_btn.clicked.connect(vm.home) + toolbar.addWidget(home_btn) + + back_btn=QtWidgets.QPushButton(text='back') + back_btn.clicked.connect(vm.back) + toolbar.addWidget(back_btn) + + forward_btn=QtWidgets.QPushButton(text='forward') + forward_btn.clicked.connect(vm.forward) + toolbar.addWidget(forward_btn) + + pan_btn=QtWidgets.QPushButton(text='pan') + pan_btn.clicked.connect(vm.pan) + toolbar.addWidget(pan_btn) + + zoom_btn=QtWidgets.QPushButton(text='zoom') + zoom_btn.clicked.connect(vm.zoom) + toolbar.addWidget(zoom_btn) + + pause_chkbtn=QtWidgets.QPushButton(text='pause') + pause_chkbtn.setCheckable(True) + pause_chkbtn.toggled.connect(vm.pauseChanged) + toolbar.addWidget(pause_chkbtn) + + text=QtWidgets.QLabel() + text.setFixedWidth(128) + toolbar.addWidget(text) + + vm.coordinatesChanged.connect(lambda x: text.setText(x)) + + layout.addLayout(toolbar) + + dynamic_canvas = FigureCanvasQTAgg(Figure(figsize=(5, 3))) + layout.addWidget(dynamic_canvas) + + vm.updateWithCanvas(canvas=dynamic_canvas, dynamic=True) + + + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") + + qInstallMessageHandler(myMessageOutput) + + # Check whether there is already a running QApplication (e.g., if running + # from an IDE). + qapp = QtWidgets.QApplication.instance() + if not qapp: + qapp = QtWidgets.QApplication(sys.argv) + + app = ApplicationWindow(vm=DemoViewModel()) + app.show() + app.activateWindow() + app.raise_() + qapp.exec_() \ No newline at end of file