from ..Qt import QT_LIB, QtCore, QtGui, QtWidgets
import atexit
import enum
import mmap
import os
import sys
import tempfile
from .. import Qt
from .. import CONFIG_OPTIONS
from .. import multiprocess as mp
from .GraphicsView import GraphicsView
__all__ = ['RemoteGraphicsView']
def serialize_mouse_enum(*args):
# PySide6 (opt-in in 6.3.1) and PyQt6
# - implemented as python enums
# - can pickle enums and flags
# - PyQt6 cannot cast to int
# PyQt5 5.12, PyQt5 5.15, PySide2 5.15, PySide6 can pickle enums but not flags
# PySide2 5.12 cannot pickle enums nor flags
# MouseButtons and KeyboardModifiers are flags
return [x if isinstance(x, enum.Enum) else int(x) for x in args]
class MouseEvent(QtGui.QMouseEvent):
@staticmethod
def get_state(obj, picklable=False):
typ = obj.type()
if isinstance(typ, int):
# PyQt6 returns an int here instead of QEvent.Type,
# but its QtGui.QMouseEvent constructor takes only QEvent.Type.
# Note however that its QtCore.QEvent constructor accepts both
# QEvent.Type and int.
typ = QtCore.QEvent.Type(typ)
lpos = obj.position() if hasattr(obj, 'position') else obj.localPos()
gpos = obj.globalPosition() if hasattr(obj, 'globalPosition') else obj.screenPos()
btn, btns, mods = obj.button(), obj.buttons(), obj.modifiers()
if picklable:
typ, btn, btns, mods = serialize_mouse_enum(typ, btn, btns, mods)
return typ, lpos, gpos, btn, btns, mods
def __init__(self, rhs):
super().__init__(*self.get_state(rhs))
def __getstate__(self):
return self.get_state(self, picklable=True)
def __setstate__(self, state):
typ, lpos, gpos, btn, btns, mods = state
typ = QtCore.QEvent.Type(typ)
btn = QtCore.Qt.MouseButton(btn)
if not isinstance(btns, enum.Enum):
btns = QtCore.Qt.MouseButtons(btns)
if not isinstance(mods, enum.Enum):
mods = QtCore.Qt.KeyboardModifiers(mods)
super().__init__(typ, lpos, gpos, btn, btns, mods)
class WheelEvent(QtGui.QWheelEvent):
@staticmethod
def get_state(obj, picklable=False):
# {PyQt6, PySide6} have position()
# {PyQt5, PySide2} 5.15 have position()
# {PyQt5, PySide2} 5.15 have posF() (contrary to C++ docs)
# {PyQt5, PySide2} 5.12 have posF()
lpos = obj.position() if hasattr(obj, 'position') else obj.posF()
gpos = obj.globalPosition() if hasattr(obj, 'globalPosition') else obj.globalPosF()
pixdel, angdel, btns = obj.pixelDelta(), obj.angleDelta(), obj.buttons()
mods, phase, inverted = obj.modifiers(), obj.phase(), obj.inverted()
if picklable:
btns, mods, phase = serialize_mouse_enum(btns, mods, phase)
return lpos, gpos, pixdel, angdel, btns, mods, phase, inverted
def __init__(self, rhs):
items = list(self.get_state(rhs))
items[1] = items[0] # gpos = lpos
super().__init__(*items)
def __getstate__(self):
return self.get_state(self, picklable=True)
def __setstate__(self, state):
pos, gpos, pixdel, angdel, btns, mods, phase, inverted = state
if not isinstance(btns, enum.Enum):
btns = QtCore.Qt.MouseButtons(btns)
if not isinstance(mods, enum.Enum):
mods = QtCore.Qt.KeyboardModifiers(mods)
phase = QtCore.Qt.ScrollPhase(phase)
super().__init__(pos, gpos, pixdel, angdel, btns, mods, phase, inverted)
class EnterEvent(QtGui.QEnterEvent):
@staticmethod
def get_state(obj):
lpos = obj.position() if hasattr(obj, 'position') else obj.localPos()
wpos = obj.scenePosition() if hasattr(obj, 'scenePosition') else obj.windowPos()
gpos = obj.globalPosition() if hasattr(obj, 'globalPosition') else obj.screenPos()
return lpos, wpos, gpos
def __init__(self, rhs):
super().__init__(*self.get_state(rhs))
def __getstate__(self):
return self.get_state(self)
def __setstate__(self, state):
super().__init__(*state)
class LeaveEvent(QtCore.QEvent):
@staticmethod
def get_state(obj, picklable=False):
typ = obj.type()
if picklable:
typ, = serialize_mouse_enum(typ)
return typ,
def __init__(self, rhs):
super().__init__(*self.get_state(rhs))
def __getstate__(self):
return self.get_state(self, picklable=True)
def __setstate__(self, state):
typ, = state
typ = QtCore.QEvent.Type(typ)
super().__init__(typ)
[docs]
class RemoteGraphicsView(QtWidgets.QWidget):
"""
Replacement for GraphicsView that does all scene management and rendering on a remote process,
while displaying on the local widget.
GraphicsItems must be created by proxy to the remote process.
"""
[docs]
def __init__(self, parent=None, *args, **kwds):
"""
The keyword arguments 'useOpenGL' and 'backgound', if specified, are passed to the remote
GraphicsView.__init__(). All other keyword arguments are passed to multiprocess.QtProcess.__init__().
"""
self._img = None
self._imgReq = None
self._sizeHint = (640,480) ## no clue why this is needed, but it seems to be the default sizeHint for GraphicsView.
## without it, the widget will not compete for space against another GraphicsView.
QtWidgets.QWidget.__init__(self)
# separate local keyword arguments from remote.
remoteKwds = {}
for kwd in ['useOpenGL', 'background']:
if kwd in kwds:
remoteKwds[kwd] = kwds.pop(kwd)
self._proc = mp.QtProcess(**kwds)
self.pg = self._proc._import('pyqtgraph')
self.pg.setConfigOptions(**CONFIG_OPTIONS)
rpgRemote = self._proc._import('pyqtgraph.widgets.RemoteGraphicsView')
self._view = rpgRemote.Renderer(*args, **remoteKwds)
self._view._setProxyOptions(deferGetattr=True)
self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus)
self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
self.setMouseTracking(True)
self.shm = None
shmFileName = self._view.shmFileName()
if sys.platform == 'win32':
opener = lambda path, flags: os.open(path, flags | os.O_TEMPORARY)
else:
opener = None
self.shmFile = open(shmFileName, 'rb', opener=opener)
self._view.sceneRendered.connect(mp.proxy(self.remoteSceneChanged)) #, callSync='off'))
## Note: we need synchronous signals
## even though there is no return value--
## this informs the renderer that it is
## safe to begin rendering again.
for method in ['scene', 'setCentralItem']:
setattr(self, method, getattr(self._view, method))
def resizeEvent(self, ev):
ret = super().resizeEvent(ev)
self._view.resize(self.size(), _callSync='off')
return ret
def sizeHint(self):
return QtCore.QSize(*self._sizeHint)
def remoteSceneChanged(self, data):
w, h, size = data
if self.shm is None or self.shm.size != size:
if self.shm is not None:
self.shm.close()
self.shm = mmap.mmap(self.shmFile.fileno(), size, access=mmap.ACCESS_READ)
self._img = QtGui.QImage(self.shm, w, h, QtGui.QImage.Format.Format_RGB32).copy()
self.update()
def paintEvent(self, ev):
if self._img is None:
return
p = QtGui.QPainter(self)
p.drawImage(self.rect(), self._img, self._img.rect())
p.end()
def mousePressEvent(self, ev):
self._view.mousePressEvent(MouseEvent(ev), _callSync='off')
ev.accept()
return super().mousePressEvent(ev)
def mouseReleaseEvent(self, ev):
self._view.mouseReleaseEvent(MouseEvent(ev), _callSync='off')
ev.accept()
return super().mouseReleaseEvent(ev)
def mouseMoveEvent(self, ev):
self._view.mouseMoveEvent(MouseEvent(ev), _callSync='off')
ev.accept()
return super().mouseMoveEvent(ev)
def wheelEvent(self, ev):
self._view.wheelEvent(WheelEvent(ev), _callSync='off')
ev.accept()
return super().wheelEvent(ev)
def enterEvent(self, ev):
self._view.enterEvent(EnterEvent(ev), _callSync='off')
return super().enterEvent(ev)
def leaveEvent(self, ev):
self._view.leaveEvent(LeaveEvent(ev), _callSync='off')
return super().leaveEvent(ev)
[docs]
def remoteProcess(self):
"""Return the remote process handle. (see multiprocess.remoteproxy.RemoteEventHandler)"""
return self._proc
[docs]
def close(self):
"""Close the remote process. After this call, the widget will no longer be updated."""
self._view.sceneRendered.disconnect()
self._proc.close()
class Renderer(GraphicsView):
## Created by the remote process to handle render requests
sceneRendered = QtCore.Signal(object)
def __init__(self, *args, **kwds):
## Create shared memory for rendered image
self.shmFile = tempfile.NamedTemporaryFile(prefix='pyqtgraph_shmem_')
size = mmap.PAGESIZE
self.shmFile.write(b'\x00' * size)
self.shmFile.flush()
self.shm = mmap.mmap(self.shmFile.fileno(), size, access=mmap.ACCESS_WRITE)
atexit.register(self.close)
GraphicsView.__init__(self, *args, **kwds)
self.scene().changed.connect(self.update)
self.img = None
self.renderTimer = QtCore.QTimer()
self.renderTimer.timeout.connect(self.renderView)
self.renderTimer.start(16)
def close(self):
self.shm.close()
self.shmFile.close()
def shmFileName(self):
return self.shmFile.name
def update(self):
self.img = None
return super().update()
def resize(self, size):
oldSize = self.size()
super().resize(size)
self.resizeEvent(QtGui.QResizeEvent(size, oldSize))
self.update()
def renderView(self):
if self.img is None:
## make sure shm is large enough and get its address
if self.width() == 0 or self.height() == 0:
return
dpr = self.devicePixelRatioF()
iwidth = int(self.width() * dpr)
iheight = int(self.height() * dpr)
size = iwidth * iheight * 4
if size > self.shm.size():
try:
self.shm.resize(size)
except SystemError:
# actually, the platforms on which resize() _does_ work
# can also take this codepath
self.shm.close()
fd = self.shmFile.fileno()
os.ftruncate(fd, size)
self.shm = mmap.mmap(fd, size, access=mmap.ACCESS_WRITE)
## render the scene directly to shared memory
# see functions.py::ndarray_to_qimage() for rationale
if QT_LIB.startswith('PyQt'):
# PyQt5, PyQt6 >= 6.0.1
img_ptr = int(Qt.sip.voidptr(self.shm))
else:
# PySide2, PySide6
img_ptr = self.shm
self.img = QtGui.QImage(img_ptr, iwidth, iheight, QtGui.QImage.Format.Format_RGB32)
self.img.setDevicePixelRatio(dpr)
self.img.fill(0xffffffff)
p = QtGui.QPainter(self.img)
self.render(p, self.viewRect(), self.rect())
p.end()
self.sceneRendered.emit((iwidth, iheight, self.shm.size()))