diff --git a/Qt.py b/Qt.py index 2fcff32f..efbe66c4 100644 --- a/Qt.py +++ b/Qt.py @@ -11,6 +11,8 @@ This module exists to smooth out some of the differences between PySide and PyQt import sys, re +from .python2_3 import asUnicode + ## Automatically determine whether to use PyQt or PySide. ## This is done by first checking to see whether one of the libraries ## is already imported. If not, then attempt to import PyQt4, then PySide. @@ -31,6 +33,10 @@ else: if USE_PYSIDE: from PySide import QtGui, QtCore, QtOpenGL, QtSvg + try: + from PySide import QtTest + except ImportError: + pass import PySide try: from PySide import shiboken @@ -56,18 +62,29 @@ if USE_PYSIDE: # Credit: # http://stackoverflow.com/questions/4442286/python-code-genration-with-pyside-uic/14195313#14195313 + class StringIO(object): + """Alternative to built-in StringIO needed to circumvent unicode/ascii issues""" + def __init__(self): + self.data = [] + + def write(self, data): + self.data.append(data) + + def getvalue(self): + return ''.join(map(asUnicode, self.data)).encode('utf8') + def loadUiType(uiFile): """ Pyside "loadUiType" command like PyQt4 has one, so we have to convert the ui file to py code in-memory first and then execute it in a special frame to retrieve the form_class. """ import pysideuic import xml.etree.ElementTree as xml - from io import StringIO + #from io import StringIO parsed = xml.parse(uiFile) widget_class = parsed.find('widget').get('class') form_class = parsed.find('class').text - + with open(uiFile, 'r') as f: o = StringIO() frame = {} @@ -93,6 +110,10 @@ else: from PyQt4 import QtOpenGL except ImportError: pass + try: + from PyQt4 import QtTest + except ImportError: + pass import sip diff --git a/SignalProxy.py b/SignalProxy.py index 6f9b9112..d36282fa 100644 --- a/SignalProxy.py +++ b/SignalProxy.py @@ -2,6 +2,7 @@ from .Qt import QtCore from .ptime import time from . import ThreadsafeTimer +import weakref __all__ = ['SignalProxy'] @@ -34,7 +35,7 @@ class SignalProxy(QtCore.QObject): self.timer = ThreadsafeTimer.ThreadsafeTimer() self.timer.timeout.connect(self.flush) self.block = False - self.slot = slot + self.slot = weakref.ref(slot) self.lastFlushTime = None if slot is not None: self.sigDelayed.connect(slot) @@ -80,7 +81,7 @@ class SignalProxy(QtCore.QObject): except: pass try: - self.sigDelayed.disconnect(self.slot) + self.sigDelayed.disconnect(self.slot()) except: pass diff --git a/__init__.py b/__init__.py index 30160565..01e84c49 100644 --- a/__init__.py +++ b/__init__.py @@ -257,7 +257,7 @@ from .graphicsWindows import * from .SignalProxy import * from .colormap import * from .ptime import time -from pyqtgraph.Qt import isQObjectAlive +from .Qt import isQObjectAlive ############################################################## diff --git a/dockarea/Container.py b/dockarea/Container.py index 277375f3..c3225edf 100644 --- a/dockarea/Container.py +++ b/dockarea/Container.py @@ -22,6 +22,9 @@ class Container(object): return None def insert(self, new, pos=None, neighbor=None): + # remove from existing parent first + new.setParent(None) + if not isinstance(new, list): new = [new] if neighbor is None: diff --git a/dockarea/Dock.py b/dockarea/Dock.py index d3cfcbb6..28d4244b 100644 --- a/dockarea/Dock.py +++ b/dockarea/Dock.py @@ -8,11 +8,13 @@ class Dock(QtGui.QWidget, DockDrop): sigStretchChanged = QtCore.Signal() - def __init__(self, name, area=None, size=(10, 10), widget=None, hideTitle=False, autoOrientation=True): + def __init__(self, name, area=None, size=(10, 10), widget=None, hideTitle=False, autoOrientation=True, closable=False): QtGui.QWidget.__init__(self) DockDrop.__init__(self) self.area = area - self.label = DockLabel(name, self) + self.label = DockLabel(name, self, closable) + if closable: + self.label.sigCloseClicked.connect(self.close) self.labelHidden = False self.moveLabel = True ## If false, the dock is no longer allowed to move the label. self.autoOrient = autoOrientation @@ -35,30 +37,30 @@ class Dock(QtGui.QWidget, DockDrop): #self.titlePos = 'top' self.raiseOverlay() self.hStyle = """ - Dock > QWidget { - border: 1px solid #000; - border-radius: 5px; - border-top-left-radius: 0px; - border-top-right-radius: 0px; + Dock > QWidget { + border: 1px solid #000; + border-radius: 5px; + border-top-left-radius: 0px; + border-top-right-radius: 0px; border-top-width: 0px; }""" self.vStyle = """ - Dock > QWidget { - border: 1px solid #000; - border-radius: 5px; - border-top-left-radius: 0px; - border-bottom-left-radius: 0px; + Dock > QWidget { + border: 1px solid #000; + border-radius: 5px; + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; border-left-width: 0px; }""" self.nStyle = """ - Dock > QWidget { - border: 1px solid #000; - border-radius: 5px; + Dock > QWidget { + border: 1px solid #000; + border-radius: 5px; }""" self.dragStyle = """ - Dock > QWidget { - border: 4px solid #00F; - border-radius: 5px; + Dock > QWidget { + border: 4px solid #00F; + border-radius: 5px; }""" self.setAutoFillBackground(False) self.widgetArea.setStyleSheet(self.hStyle) @@ -79,7 +81,7 @@ class Dock(QtGui.QWidget, DockDrop): def setStretch(self, x=None, y=None): """ - Set the 'target' size for this Dock. + Set the 'target' size for this Dock. The actual size will be determined by comparing this Dock's stretch value to the rest of the docks it shares space with. """ @@ -130,7 +132,7 @@ class Dock(QtGui.QWidget, DockDrop): Sets the orientation of the title bar for this Dock. Must be one of 'auto', 'horizontal', or 'vertical'. By default ('auto'), the orientation is determined - based on the aspect ratio of the Dock. + based on the aspect ratio of the Dock. """ #print self.name(), "setOrientation", o, force if o == 'auto' and self.autoOrient: @@ -175,7 +177,7 @@ class Dock(QtGui.QWidget, DockDrop): def addWidget(self, widget, row=None, col=0, rowspan=1, colspan=1): """ - Add a new widget to the interior of this Dock. + Add a new widget to the interior of this Dock. Each Dock uses a QGridLayout to arrange widgets within. """ if row is None: @@ -239,11 +241,13 @@ class Dock(QtGui.QWidget, DockDrop): def dropEvent(self, *args): DockDrop.dropEvent(self, *args) + class DockLabel(VerticalLabel): sigClicked = QtCore.Signal(object, object) + sigCloseClicked = QtCore.Signal() - def __init__(self, text, dock): + def __init__(self, text, dock, showCloseButton): self.dim = False self.fixedWidth = False VerticalLabel.__init__(self, text, orientation='horizontal', forceWidth=False) @@ -251,10 +255,13 @@ class DockLabel(VerticalLabel): self.dock = dock self.updateStyle() self.setAutoFillBackground(False) + self.startedDrag = False - #def minimumSizeHint(self): - ##sh = QtGui.QWidget.minimumSizeHint(self) - #return QtCore.QSize(20, 20) + self.closeButton = None + if showCloseButton: + self.closeButton = QtGui.QToolButton(self) + self.closeButton.clicked.connect(self.sigCloseClicked) + self.closeButton.setIcon(QtGui.QApplication.style().standardIcon(QtGui.QStyle.SP_TitleBarCloseButton)) def updateStyle(self): r = '3px' @@ -268,28 +275,28 @@ class DockLabel(VerticalLabel): border = '#55B' if self.orientation == 'vertical': - self.vStyle = """DockLabel { - background-color : %s; - color : %s; - border-top-right-radius: 0px; - border-top-left-radius: %s; - border-bottom-right-radius: 0px; - border-bottom-left-radius: %s; - border-width: 0px; + self.vStyle = """DockLabel { + background-color : %s; + color : %s; + border-top-right-radius: 0px; + border-top-left-radius: %s; + border-bottom-right-radius: 0px; + border-bottom-left-radius: %s; + border-width: 0px; border-right: 2px solid %s; padding-top: 3px; padding-bottom: 3px; }""" % (bg, fg, r, r, border) self.setStyleSheet(self.vStyle) else: - self.hStyle = """DockLabel { - background-color : %s; - color : %s; - border-top-right-radius: %s; - border-top-left-radius: %s; - border-bottom-right-radius: 0px; - border-bottom-left-radius: 0px; - border-width: 0px; + self.hStyle = """DockLabel { + background-color : %s; + color : %s; + border-top-right-radius: %s; + border-top-left-radius: %s; + border-bottom-right-radius: 0px; + border-bottom-left-radius: 0px; + border-width: 0px; border-bottom: 2px solid %s; padding-left: 3px; padding-right: 3px; @@ -315,11 +322,9 @@ class DockLabel(VerticalLabel): if not self.startedDrag and (ev.pos() - self.pressPos).manhattanLength() > QtGui.QApplication.startDragDistance(): self.dock.startDrag() ev.accept() - #print ev.pos() def mouseReleaseEvent(self, ev): if not self.startedDrag: - #self.emit(QtCore.SIGNAL('clicked'), self, ev) self.sigClicked.emit(self, ev) ev.accept() @@ -327,13 +332,14 @@ class DockLabel(VerticalLabel): if ev.button() == QtCore.Qt.LeftButton: self.dock.float() - #def paintEvent(self, ev): - #p = QtGui.QPainter(self) - ##p.setBrush(QtGui.QBrush(QtGui.QColor(100, 100, 200))) - #p.setPen(QtGui.QPen(QtGui.QColor(50, 50, 100))) - #p.drawRect(self.rect().adjusted(0, 0, -1, -1)) - - #VerticalLabel.paintEvent(self, ev) - - - + def resizeEvent (self, ev): + if self.closeButton: + if self.orientation == 'vertical': + size = ev.size().width() + pos = QtCore.QPoint(0, 0) + else: + size = ev.size().height() + pos = QtCore.QPoint(ev.size().width() - size, 0) + self.closeButton.setFixedSize(QtCore.QSize(size, size)) + self.closeButton.move(pos) + super(DockLabel,self).resizeEvent(ev) diff --git a/exporters/CSVExporter.py b/exporters/CSVExporter.py index b0cf5af5..6ed4cf07 100644 --- a/exporters/CSVExporter.py +++ b/exporters/CSVExporter.py @@ -52,10 +52,11 @@ class CSVExporter(Exporter): numRows = max([len(d[0]) for d in data]) for i in range(numRows): for d in data: - if i < len(d[0]): - fd.write(numFormat % d[0][i] + sep + numFormat % d[1][i] + sep) - else: - fd.write(' %s %s' % (sep, sep)) + for j in [0, 1]: + if i < len(d[j]): + fd.write(numFormat % d[j][i] + sep) + else: + fd.write(' %s' % sep) fd.write('\n') fd.close() diff --git a/exporters/tests/test_csv.py b/exporters/tests/test_csv.py new file mode 100644 index 00000000..70c69c72 --- /dev/null +++ b/exporters/tests/test_csv.py @@ -0,0 +1,49 @@ +""" +SVG export test +""" +import pyqtgraph as pg +import pyqtgraph.exporters +import csv + +app = pg.mkQApp() + +def approxeq(a, b): + return (a-b) <= ((a + b) * 1e-6) + +def test_CSVExporter(): + plt = pg.plot() + y1 = [1,3,2,3,1,6,9,8,4,2] + plt.plot(y=y1, name='myPlot') + + y2 = [3,4,6,1,2,4,2,3,5,3,5,1,3] + x2 = pg.np.linspace(0, 1.0, len(y2)) + plt.plot(x=x2, y=y2) + + y3 = [1,5,2,3,4,6,1,2,4,2,3,5,3] + x3 = pg.np.linspace(0, 1.0, len(y3)+1) + plt.plot(x=x3, y=y3, stepMode=True) + + ex = pg.exporters.CSVExporter(plt.plotItem) + ex.export(fileName='test.csv') + + r = csv.reader(open('test.csv', 'r')) + lines = [line for line in r] + header = lines.pop(0) + assert header == ['myPlot_x', 'myPlot_y', 'x', 'y', 'x', 'y'] + + i = 0 + for vals in lines: + vals = list(map(str.strip, vals)) + assert (i >= len(y1) and vals[0] == '') or approxeq(float(vals[0]), i) + assert (i >= len(y1) and vals[1] == '') or approxeq(float(vals[1]), y1[i]) + + assert (i >= len(x2) and vals[2] == '') or approxeq(float(vals[2]), x2[i]) + assert (i >= len(y2) and vals[3] == '') or approxeq(float(vals[3]), y2[i]) + + assert (i >= len(x3) and vals[4] == '') or approxeq(float(vals[4]), x3[i]) + assert (i >= len(y3) and vals[5] == '') or approxeq(float(vals[5]), y3[i]) + i += 1 + +if __name__ == '__main__': + test_CSVExporter() + \ No newline at end of file diff --git a/functions.py b/functions.py index 2325186c..77643c99 100644 --- a/functions.py +++ b/functions.py @@ -538,7 +538,6 @@ def interpolateArray(data, x, default=0.0): prof = debug.Profiler() - result = np.empty(x.shape[:-1] + data.shape, dtype=data.dtype) nd = data.ndim md = x.shape[-1] diff --git a/graphicsItems/AxisItem.py b/graphicsItems/AxisItem.py index c25f7a7f..ededed56 100644 --- a/graphicsItems/AxisItem.py +++ b/graphicsItems/AxisItem.py @@ -55,6 +55,8 @@ class AxisItem(GraphicsWidget): ], 'showValues': showValues, 'tickLength': maxTickLength, + 'maxTickLevel': 2, + 'maxTextLevel': 2, } self.textWidth = 30 ## Keeps track of maximum width / height of tick text @@ -68,6 +70,7 @@ class AxisItem(GraphicsWidget): self.tickFont = None self._tickLevels = None ## used to override the automatic ticking system with explicit ticks + self._tickSpacing = None # used to override default tickSpacing method self.scale = 1.0 self.autoSIPrefix = True self.autoSIPrefixScale = 1.0 @@ -161,7 +164,11 @@ class AxisItem(GraphicsWidget): self.scene().removeItem(self) def setGrid(self, grid): - """Set the alpha value for the grid, or False to disable.""" + """Set the alpha value (0-255) for the grid, or False to disable. + + When grid lines are enabled, the axis tick lines are extended to cover + the extent of the linked ViewBox, if any. + """ self.grid = grid self.picture = None self.prepareGeometryChange() @@ -229,7 +236,7 @@ class AxisItem(GraphicsWidget): without any scaling prefix (eg, 'V' instead of 'mV'). The scaling prefix will be automatically prepended based on the range of data displayed. - **args All extra keyword arguments become CSS style options for + **args All extra keyword arguments become CSS style options for the tag which will surround the axis label and units. ============== ============================================================= @@ -454,7 +461,10 @@ class AxisItem(GraphicsWidget): else: if newRange is None: newRange = view.viewRange()[0] - self.setRange(*newRange) + if view.xInverted(): + self.setRange(*newRange[::-1]) + else: + self.setRange(*newRange) def boundingRect(self): linkedView = self.linkedView() @@ -510,6 +520,37 @@ class AxisItem(GraphicsWidget): self.picture = None self.update() + def setTickSpacing(self, major=None, minor=None, levels=None): + """ + Explicitly determine the spacing of major and minor ticks. This + overrides the default behavior of the tickSpacing method, and disables + the effect of setTicks(). Arguments may be either *major* and *minor*, + or *levels* which is a list of (spacing, offset) tuples for each + tick level desired. + + If no arguments are given, then the default behavior of tickSpacing + is enabled. + + Examples:: + + # two levels, all offsets = 0 + axis.setTickSpacing(5, 1) + # three levels, all offsets = 0 + axis.setTickSpacing([(3, 0), (1, 0), (0.25, 0)]) + # reset to default + axis.setTickSpacing() + """ + + if levels is None: + if major is None: + levels = None + else: + levels = [(major, 0), (minor, 0)] + self._tickSpacing = levels + self.picture = None + self.update() + + def tickSpacing(self, minVal, maxVal, size): """Return values describing the desired spacing and offset of ticks. @@ -525,6 +566,10 @@ class AxisItem(GraphicsWidget): ... ] """ + # First check for override tick spacing + if self._tickSpacing is not None: + return self._tickSpacing + dif = abs(maxVal - minVal) if dif == 0: return [] @@ -550,12 +595,13 @@ class AxisItem(GraphicsWidget): #(intervals[minorIndex], 0) ## Pretty, but eats up CPU ] - ## decide whether to include the last level of ticks - minSpacing = min(size / 20., 30.) - maxTickCount = size / minSpacing - if dif / intervals[minorIndex] <= maxTickCount: - levels.append((intervals[minorIndex], 0)) - return levels + if self.style['maxTickLevel'] >= 2: + ## decide whether to include the last level of ticks + minSpacing = min(size / 20., 30.) + maxTickCount = size / minSpacing + if dif / intervals[minorIndex] <= maxTickCount: + levels.append((intervals[minorIndex], 0)) + return levels @@ -581,8 +627,6 @@ class AxisItem(GraphicsWidget): #(intervals[intIndexes[0]], 0) #] - - def tickValues(self, minVal, maxVal, size): """ Return the values and spacing of ticks to draw:: @@ -756,8 +800,6 @@ class AxisItem(GraphicsWidget): values.append(val) strings.append(strn) - textLevel = 1 ## draw text at this scale level - ## determine mapping between tick values and local coordinates dif = self.range[1] - self.range[0] if dif == 0: @@ -846,7 +888,7 @@ class AxisItem(GraphicsWidget): if not self.style['showValues']: return (axisSpec, tickSpecs, textSpecs) - for i in range(len(tickLevels)): + for i in range(min(len(tickLevels), self.style['maxTextLevel']+1)): ## Get the list of strings to display for this level if tickStrings is None: spacing, values = tickLevels[i] diff --git a/graphicsItems/ErrorBarItem.py b/graphicsItems/ErrorBarItem.py index 7b681389..986c5140 100644 --- a/graphicsItems/ErrorBarItem.py +++ b/graphicsItems/ErrorBarItem.py @@ -8,15 +8,7 @@ __all__ = ['ErrorBarItem'] class ErrorBarItem(GraphicsObject): def __init__(self, **opts): """ - Valid keyword options are: - x, y, height, width, top, bottom, left, right, beam, pen - - x and y must be numpy arrays specifying the coordinates of data points. - height, width, top, bottom, left, right, and beam may be numpy arrays, - single values, or None to disable. All values should be positive. - - If height is specified, it overrides top and bottom. - If width is specified, it overrides left and right. + All keyword arguments are passed to setData(). """ GraphicsObject.__init__(self) self.opts = dict( @@ -31,14 +23,37 @@ class ErrorBarItem(GraphicsObject): beam=None, pen=None ) - self.setOpts(**opts) + self.setData(**opts) + + def setData(self, **opts): + """ + Update the data in the item. All arguments are optional. - def setOpts(self, **opts): + Valid keyword options are: + x, y, height, width, top, bottom, left, right, beam, pen + + * x and y must be numpy arrays specifying the coordinates of data points. + * height, width, top, bottom, left, right, and beam may be numpy arrays, + single values, or None to disable. All values should be positive. + * top, bottom, left, and right specify the lengths of bars extending + in each direction. + * If height is specified, it overrides top and bottom. + * If width is specified, it overrides left and right. + * beam specifies the width of the beam at the end of each bar. + * pen may be any single argument accepted by pg.mkPen(). + + This method was added in version 0.9.9. For prior versions, use setOpts. + """ self.opts.update(opts) self.path = None self.update() + self.prepareGeometryChange() self.informViewBoundsChanged() + def setOpts(self, **opts): + # for backward compatibility + self.setData(**opts) + def drawPath(self): p = QtGui.QPainterPath() diff --git a/graphicsItems/GraphicsItem.py b/graphicsItems/GraphicsItem.py index 9fa323e2..2ca35193 100644 --- a/graphicsItems/GraphicsItem.py +++ b/graphicsItems/GraphicsItem.py @@ -102,7 +102,7 @@ class GraphicsItem(object): Extends deviceTransform to automatically determine the viewportTransform. """ if self._exportOpts is not False and 'painter' in self._exportOpts: ## currently exporting; device transform may be different. - return self._exportOpts['painter'].deviceTransform() + return self._exportOpts['painter'].deviceTransform() * self.sceneTransform() if viewportTransform is None: view = self.getViewWidget() @@ -318,6 +318,8 @@ class GraphicsItem(object): vt = self.deviceTransform() if vt is None: return None + if isinstance(obj, QtCore.QPoint): + obj = QtCore.QPointF(obj) vt = fn.invertQTransform(vt) return vt.map(obj) diff --git a/graphicsItems/HistogramLUTItem.py b/graphicsItems/HistogramLUTItem.py index 71577422..6a915902 100644 --- a/graphicsItems/HistogramLUTItem.py +++ b/graphicsItems/HistogramLUTItem.py @@ -17,6 +17,7 @@ from .. import functions as fn import numpy as np from .. import debug as debug +import weakref __all__ = ['HistogramLUTItem'] @@ -42,7 +43,7 @@ class HistogramLUTItem(GraphicsWidget): """ GraphicsWidget.__init__(self) self.lut = None - self.imageItem = None + self.imageItem = lambda: None # fake a dead weakref self.layout = QtGui.QGraphicsGridLayout() self.setLayout(self.layout) @@ -138,7 +139,7 @@ class HistogramLUTItem(GraphicsWidget): #self.region.setBounds([vr.top(), vr.bottom()]) def setImageItem(self, img): - self.imageItem = img + self.imageItem = weakref.ref(img) img.sigImageChanged.connect(self.imageChanged) img.setLookupTable(self.getLookupTable) ## send function pointer, not the result #self.gradientChanged() @@ -150,11 +151,11 @@ class HistogramLUTItem(GraphicsWidget): self.update() def gradientChanged(self): - if self.imageItem is not None: + if self.imageItem() is not None: if self.gradient.isLookupTrivial(): - self.imageItem.setLookupTable(None) #lambda x: x.astype(np.uint8)) + self.imageItem().setLookupTable(None) #lambda x: x.astype(np.uint8)) else: - self.imageItem.setLookupTable(self.getLookupTable) ## send function pointer, not the result + self.imageItem().setLookupTable(self.getLookupTable) ## send function pointer, not the result self.lut = None #if self.imageItem is not None: @@ -178,14 +179,14 @@ class HistogramLUTItem(GraphicsWidget): #self.update() def regionChanging(self): - if self.imageItem is not None: - self.imageItem.setLevels(self.region.getRegion()) + if self.imageItem() is not None: + self.imageItem().setLevels(self.region.getRegion()) self.sigLevelsChanged.emit(self) self.update() def imageChanged(self, autoLevel=False, autoRange=False): profiler = debug.Profiler() - h = self.imageItem.getHistogram() + h = self.imageItem().getHistogram() profiler('get histogram') if h[0] is None: return diff --git a/graphicsItems/InfiniteLine.py b/graphicsItems/InfiniteLine.py index dfe2a4c1..8108c3cf 100644 --- a/graphicsItems/InfiniteLine.py +++ b/graphicsItems/InfiniteLine.py @@ -59,7 +59,9 @@ class InfiniteLine(GraphicsObject): if pen is None: pen = (200, 200, 100) + self.setPen(pen) + self.setHoverPen(color=(255,0,0), width=self.pen.width()) self.currentPen = self.pen #self.setFlag(self.ItemSendsScenePositionChanges) @@ -77,8 +79,22 @@ class InfiniteLine(GraphicsObject): """Set the pen for drawing the line. Allowable arguments are any that are valid for :func:`mkPen `.""" self.pen = fn.mkPen(*args, **kwargs) - self.currentPen = self.pen - self.update() + if not self.mouseHovering: + self.currentPen = self.pen + self.update() + + def setHoverPen(self, *args, **kwargs): + """Set the pen for drawing the line while the mouse hovers over it. + Allowable arguments are any that are valid + for :func:`mkPen `. + + If the line is not movable, then hovering is also disabled. + + Added in version 0.9.9.""" + self.hoverPen = fn.mkPen(*args, **kwargs) + if self.mouseHovering: + self.currentPen = self.hoverPen + self.update() def setAngle(self, angle): """ @@ -168,8 +184,9 @@ class InfiniteLine(GraphicsObject): px = self.pixelLength(direction=Point(1,0), ortho=True) ## get pixel length orthogonal to the line if px is None: px = 0 - br.setBottom(-px*4) - br.setTop(px*4) + w = (max(4, self.pen.width()/2, self.hoverPen.width()/2)+1) * px + br.setBottom(-w) + br.setTop(w) return br.normalized() def paint(self, p, *args): @@ -183,25 +200,6 @@ class InfiniteLine(GraphicsObject): return None ## x axis should never be auto-scaled else: return (0,0) - - #def mousePressEvent(self, ev): - #if self.movable and ev.button() == QtCore.Qt.LeftButton: - #ev.accept() - #self.pressDelta = self.mapToParent(ev.pos()) - QtCore.QPointF(*self.p) - #else: - #ev.ignore() - - #def mouseMoveEvent(self, ev): - #self.setPos(self.mapToParent(ev.pos()) - self.pressDelta) - ##self.emit(QtCore.SIGNAL('dragged'), self) - #self.sigDragged.emit(self) - #self.hasMoved = True - - #def mouseReleaseEvent(self, ev): - #if self.hasMoved and ev.button() == QtCore.Qt.LeftButton: - #self.hasMoved = False - ##self.emit(QtCore.SIGNAL('positionChangeFinished'), self) - #self.sigPositionChangeFinished.emit(self) def mouseDragEvent(self, ev): if self.movable and ev.button() == QtCore.Qt.LeftButton: @@ -239,12 +237,12 @@ class InfiniteLine(GraphicsObject): self.setMouseHover(False) def setMouseHover(self, hover): - ## Inform the item that the mouse is(not) hovering over it + ## Inform the item that the mouse is (not) hovering over it if self.mouseHovering == hover: return self.mouseHovering = hover if hover: - self.currentPen = fn.mkPen(255, 0,0) + self.currentPen = self.hoverPen else: self.currentPen = self.pen self.update() diff --git a/graphicsItems/IsocurveItem.py b/graphicsItems/IsocurveItem.py index 897df999..4474e29a 100644 --- a/graphicsItems/IsocurveItem.py +++ b/graphicsItems/IsocurveItem.py @@ -35,11 +35,6 @@ class IsocurveItem(GraphicsObject): self.setPen(pen) self.setData(data, level) - - - #if data is not None and level is not None: - #self.updateLines(data, level) - def setData(self, data, level=None): """ @@ -65,6 +60,7 @@ class IsocurveItem(GraphicsObject): """Set the level at which the isocurve is drawn.""" self.level = level self.path = None + self.prepareGeometryChange() self.update() diff --git a/graphicsItems/LegendItem.py b/graphicsItems/LegendItem.py index ea6798fb..20d6416e 100644 --- a/graphicsItems/LegendItem.py +++ b/graphicsItems/LegendItem.py @@ -75,7 +75,7 @@ class LegendItem(GraphicsWidget, GraphicsWidgetAnchor): sample = item else: sample = ItemSample(item) - row = len(self.items) + row = self.layout.rowCount() self.items.append((sample, label)) self.layout.addItem(sample, row, 0) self.layout.addItem(label, row, 1) diff --git a/graphicsItems/PlotDataItem.py b/graphicsItems/PlotDataItem.py index 3e760ce1..befc5783 100644 --- a/graphicsItems/PlotDataItem.py +++ b/graphicsItems/PlotDataItem.py @@ -546,7 +546,7 @@ class PlotDataItem(GraphicsObject): if view is None or not view.autoRangeEnabled()[0]: # this option presumes that x-values have uniform spacing range = self.viewRect() - if range is not None: + if range is not None and len(x) > 1: dx = float(x[-1]-x[0]) / (len(x)-1) # clip to visible region extended by downsampling value x0 = np.clip(int((range.left()-x[0])/dx)-1*ds , 0, len(x)-1) diff --git a/graphicsItems/PlotItem/PlotItem.py b/graphicsItems/PlotItem/PlotItem.py index 5c102d95..8292875c 100644 --- a/graphicsItems/PlotItem/PlotItem.py +++ b/graphicsItems/PlotItem/PlotItem.py @@ -78,6 +78,7 @@ class PlotItem(GraphicsWidget): :func:`disableAutoRange `, :func:`setAspectLocked `, :func:`invertY `, + :func:`invertX `, :func:`register `, :func:`unregister ` @@ -299,7 +300,7 @@ class PlotItem(GraphicsWidget): for m in ['setXRange', 'setYRange', 'setXLink', 'setYLink', 'setAutoPan', # NOTE: 'setAutoVisible', 'setRange', 'autoRange', 'viewRect', 'viewRange', # If you update this list, please 'setMouseEnabled', 'setLimits', 'enableAutoRange', 'disableAutoRange', # update the class docstring - 'setAspectLocked', 'invertY', 'register', 'unregister']: # as well. + 'setAspectLocked', 'invertY', 'invertX', 'register', 'unregister']: # as well. def _create_method(name): def method(self, *args, **kwargs): diff --git a/graphicsItems/ROI.py b/graphicsItems/ROI.py index d51f75df..f3ebd992 100644 --- a/graphicsItems/ROI.py +++ b/graphicsItems/ROI.py @@ -1804,13 +1804,56 @@ class PolyLineROI(ROI): self.segments = [] ROI.__init__(self, pos, size=[1,1], **args) - for p in positions: - self.addFreeHandle(p) + self.setPoints(positions) + #for p in positions: + #self.addFreeHandle(p) + #start = -1 if self.closed else 0 + #for i in range(start, len(self.handles)-1): + #self.addSegment(self.handles[i]['item'], self.handles[i+1]['item']) + + def setPoints(self, points, closed=None): + """ + Set the complete sequence of points displayed by this ROI. + + ============= ========================================================= + **Arguments** + points List of (x,y) tuples specifying handle locations to set. + closed If bool, then this will set whether the ROI is closed + (the last point is connected to the first point). If + None, then the closed mode is left unchanged. + ============= ========================================================= + + """ + if closed is not None: + self.closed = closed + + for p in points: + self.addFreeHandle(p) + start = -1 if self.closed else 0 for i in range(start, len(self.handles)-1): self.addSegment(self.handles[i]['item'], self.handles[i+1]['item']) + + + def clearPoints(self): + """ + Remove all handles and segments. + """ + while len(self.handles) > 0: + self.removeHandle(self.handles[0]['item']) + def saveState(self): + state = ROI.saveState(self) + state['closed'] = self.closed + state['points'] = [tuple(h.pos()) for h in self.getHandles()] + return state + + def setState(self, state): + ROI.setState(self, state) + self.clearPoints() + self.setPoints(state['points'], closed=state['closed']) + def addSegment(self, h1, h2, index=None): seg = LineSegmentROI(handles=(h1, h2), pen=self.pen, parent=self, movable=False) if index is None: @@ -1936,6 +1979,8 @@ class PolyLineROI(ROI): for seg in self.segments: seg.setPen(*args, **kwds) + + class LineSegmentROI(ROI): """ ROI subclass with two freely-moving handles defining a line. diff --git a/graphicsItems/ViewBox/ViewBox.py b/graphicsItems/ViewBox/ViewBox.py index 4bd2d980..d66f32ad 100644 --- a/graphicsItems/ViewBox/ViewBox.py +++ b/graphicsItems/ViewBox/ViewBox.py @@ -10,7 +10,7 @@ from copy import deepcopy from ... import debug as debug from ... import getConfigOption import sys -from pyqtgraph.Qt import isQObjectAlive +from ...Qt import isQObjectAlive __all__ = ['ViewBox'] @@ -74,12 +74,11 @@ class ViewBox(GraphicsWidget): Features: - - Scaling contents by mouse or auto-scale when contents change - - View linking--multiple views display the same data ranges - - Configurable by context menu - - Item coordinate mapping methods + * Scaling contents by mouse or auto-scale when contents change + * View linking--multiple views display the same data ranges + * Configurable by context menu + * Item coordinate mapping methods - Not really compatible with GraphicsView having the same functionality. """ sigYRangeChanged = QtCore.Signal(object, object) @@ -104,7 +103,7 @@ class ViewBox(GraphicsWidget): NamedViews = weakref.WeakValueDictionary() # name: ViewBox AllViews = weakref.WeakKeyDictionary() # ViewBox: None - def __init__(self, parent=None, border=None, lockAspect=False, enableMouse=True, invertY=False, enableMenu=True, name=None): + def __init__(self, parent=None, border=None, lockAspect=False, enableMouse=True, invertY=False, enableMenu=True, name=None, invertX=False): """ ============== ============================================================= **Arguments:** @@ -115,11 +114,16 @@ class ViewBox(GraphicsWidget): coorinates to. (or False to allow the ratio to change) *enableMouse* (bool) Whether mouse can be used to scale/pan the view *invertY* (bool) See :func:`invertY ` + *invertX* (bool) See :func:`invertX ` + *enableMenu* (bool) Whether to display a context menu when + right-clicking on the ViewBox background. + *name* (str) Used to register this ViewBox so that it appears + in the "Link axis" dropdown inside other ViewBox + context menus. This allows the user to manually link + the axes of any other view to this one. ============== ============================================================= """ - - GraphicsWidget.__init__(self, parent) self.name = None self.linksBlocked = False @@ -139,6 +143,7 @@ class ViewBox(GraphicsWidget): 'viewRange': [[0,1], [0,1]], ## actual range viewed 'yInverted': invertY, + 'xInverted': invertX, 'aspectLocked': False, ## False if aspect is unlocked, otherwise float specifies the locked ratio. 'autoRange': [True, True], ## False if auto range is disabled, ## otherwise float gives the fraction of data that is visible @@ -218,7 +223,11 @@ class ViewBox(GraphicsWidget): def register(self, name): """ Add this ViewBox to the registered list of views. - *name* will appear in the drop-down lists for axis linking in all other views. + + This allows users to manually link the axes of any other ViewBox to + this one. The specified *name* will appear in the drop-down lists for + axis linking in the context menus of all other views. + The same can be accomplished by initializing the ViewBox with the *name* attribute. """ ViewBox.AllViews[self] = None @@ -662,7 +671,10 @@ class ViewBox(GraphicsWidget): Added in version 0.9.9 """ update = False - + allowed = ['xMin', 'xMax', 'yMin', 'yMax', 'minXRange', 'maxXRange', 'minYRange', 'maxYRange'] + for kwd in kwds: + if kwd not in allowed: + raise ValueError("Invalid keyword argument '%s'." % kwd) #for kwd in ['xLimits', 'yLimits', 'minRange', 'maxRange']: #if kwd in kwds and self.state['limits'][kwd] != kwds[kwd]: #self.state['limits'][kwd] = kwds[kwd] @@ -996,7 +1008,10 @@ class ViewBox(GraphicsWidget): x2 = vr.right() else: ## views overlap; line them up upp = float(vr.width()) / vg.width() - x1 = vr.left() + (sg.x()-vg.x()) * upp + if self.xInverted(): + x1 = vr.left() + (sg.right()-vg.right()) * upp + else: + x1 = vr.left() + (sg.x()-vg.x()) * upp x2 = x1 + sg.width() * upp self.enableAutoRange(ViewBox.XAxis, False) self.setXRange(x1, x2, padding=0) @@ -1054,10 +1069,27 @@ class ViewBox(GraphicsWidget): #self.updateMatrix(changed=(False, True)) self.updateViewRange() self.sigStateChanged.emit(self) + self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][1])) def yInverted(self): return self.state['yInverted'] + def invertX(self, b=True): + """ + By default, the positive x-axis points rightward on the screen. Use invertX(True) to reverse the x-axis. + """ + if self.state['xInverted'] == b: + return + + self.state['xInverted'] = b + #self.updateMatrix(changed=(False, True)) + self.updateViewRange() + self.sigStateChanged.emit(self) + self.sigXRangeChanged.emit(self, tuple(self.state['viewRange'][0])) + + def xInverted(self): + return self.state['xInverted'] + def setAspectLocked(self, lock=True, ratio=1): """ If the aspect ratio is locked, view scaling must always preserve the aspect ratio. @@ -1280,6 +1312,8 @@ class ViewBox(GraphicsWidget): ev.ignore() def scaleHistory(self, d): + if len(self.axHistory) == 0: + return ptr = max(0, min(len(self.axHistory)-1, self.axHistoryPointer+d)) if ptr != self.axHistoryPointer: self.axHistoryPointer = ptr @@ -1454,9 +1488,10 @@ class ViewBox(GraphicsWidget): if aspect is not False and aspect != 0 and tr.height() != 0 and bounds.height() != 0: ## This is the view range aspect ratio we have requested - targetRatio = tr.width() / tr.height() + targetRatio = tr.width() / tr.height() if tr.height() != 0 else 1 ## This is the view range aspect ratio we need to obey aspect constraint - viewRatio = (bounds.width() / bounds.height()) / aspect + viewRatio = (bounds.width() / bounds.height() if bounds.height() != 0 else 1) / aspect + viewRatio = 1 if viewRatio == 0 else viewRatio # Decide which range to keep unchanged #print self.name, "aspect:", aspect, "changed:", changed, "auto:", self.state['autoRange'] @@ -1481,7 +1516,8 @@ class ViewBox(GraphicsWidget): if dx != 0: changed[0] = True viewRange[0] = [self.state['targetRange'][0][0] - dx, self.state['targetRange'][0][1] + dx] - + + # ----------- Make corrections for view limits ----------- limits = (self.state['limits']['xLimits'], self.state['limits']['yLimits']) @@ -1532,7 +1568,7 @@ class ViewBox(GraphicsWidget): changed[axis] = True #print "after applying edge limits:", viewRange[axis] - + changed = [(viewRange[i][0] != self.state['viewRange'][i][0]) or (viewRange[i][1] != self.state['viewRange'][i][1]) for i in (0,1)] self.state['viewRange'] = viewRange @@ -1554,6 +1590,7 @@ class ViewBox(GraphicsWidget): if link is not None: link.linkedViewChanged(self, ax) + self.update() self._matrixNeedsUpdate = True def updateMatrix(self, changed=None): @@ -1566,6 +1603,8 @@ class ViewBox(GraphicsWidget): scale = Point(bounds.width()/vr.width(), bounds.height()/vr.height()) if not self.state['yInverted']: scale = scale * Point(1, -1) + if self.state['xInverted']: + scale = scale * Point(-1, 1) m = QtGui.QTransform() ## First center the viewport at 0 diff --git a/graphicsItems/ViewBox/ViewBoxMenu.py b/graphicsItems/ViewBox/ViewBoxMenu.py index af142771..0e7d7912 100644 --- a/graphicsItems/ViewBox/ViewBoxMenu.py +++ b/graphicsItems/ViewBox/ViewBoxMenu.py @@ -56,7 +56,7 @@ class ViewBoxMenu(QtGui.QMenu): for sig, fn in connects: sig.connect(getattr(self, axis.lower()+fn)) - self.ctrl[0].invertCheck.hide() ## no invert for x-axis + self.ctrl[0].invertCheck.toggled.connect(self.xInvertToggled) self.ctrl[1].invertCheck.toggled.connect(self.yInvertToggled) ## exporting is handled by GraphicsScene now #self.export = QtGui.QMenu("Export") @@ -139,8 +139,9 @@ class ViewBoxMenu(QtGui.QMenu): self.ctrl[i].autoPanCheck.setChecked(state['autoPan'][i]) self.ctrl[i].visibleOnlyCheck.setChecked(state['autoVisibleOnly'][i]) - - self.ctrl[1].invertCheck.setChecked(state['yInverted']) + xy = ['x', 'y'][i] + self.ctrl[i].invertCheck.setChecked(state.get(xy+'Inverted', False)) + self.valid = True def popup(self, *args): @@ -217,19 +218,19 @@ class ViewBoxMenu(QtGui.QMenu): def yInvertToggled(self, b): self.view().invertY(b) + def xInvertToggled(self, b): + self.view().invertX(b) def exportMethod(self): act = self.sender() self.exportMethods[str(act.text())]() - def set3ButtonMode(self): self.view().setLeftButtonAction('pan') def set1ButtonMode(self): self.view().setLeftButtonAction('rect') - def setViewList(self, views): names = [''] self.viewMap.clear() diff --git a/graphicsItems/ViewBox/tests/test_ViewBox.py b/graphicsItems/ViewBox/tests/test_ViewBox.py new file mode 100644 index 00000000..7cb366c2 --- /dev/null +++ b/graphicsItems/ViewBox/tests/test_ViewBox.py @@ -0,0 +1,85 @@ +#import PySide +import pyqtgraph as pg + +app = pg.mkQApp() +qtest = pg.Qt.QtTest.QTest + +def assertMapping(vb, r1, r2): + assert vb.mapFromView(r1.topLeft()) == r2.topLeft() + assert vb.mapFromView(r1.bottomLeft()) == r2.bottomLeft() + assert vb.mapFromView(r1.topRight()) == r2.topRight() + assert vb.mapFromView(r1.bottomRight()) == r2.bottomRight() + +def test_ViewBox(): + global app, win, vb + QRectF = pg.QtCore.QRectF + + win = pg.GraphicsWindow() + win.ci.layout.setContentsMargins(0,0,0,0) + win.resize(200, 200) + win.show() + vb = win.addViewBox() + + vb.setRange(xRange=[0, 10], yRange=[0, 10], padding=0) + + # required to make mapFromView work properly. + qtest.qWaitForWindowShown(win) + vb.update() + + g = pg.GridItem() + vb.addItem(g) + + app.processEvents() + + w = vb.geometry().width() + h = vb.geometry().height() + view1 = QRectF(0, 0, 10, 10) + size1 = QRectF(0, h, w, -h) + assertMapping(vb, view1, size1) + + # test resize + win.resize(400, 400) + app.processEvents() + w = vb.geometry().width() + h = vb.geometry().height() + size1 = QRectF(0, h, w, -h) + assertMapping(vb, view1, size1) + + # now lock aspect + vb.setAspectLocked() + + # test wide resize + win.resize(800, 400) + app.processEvents() + w = vb.geometry().width() + h = vb.geometry().height() + view1 = QRectF(-5, 0, 20, 10) + size1 = QRectF(0, h, w, -h) + assertMapping(vb, view1, size1) + + # test tall resize + win.resize(400, 800) + app.processEvents() + w = vb.geometry().width() + h = vb.geometry().height() + view1 = QRectF(0, -5, 10, 20) + size1 = QRectF(0, h, w, -h) + assertMapping(vb, view1, size1) + + # test limits + resize (aspect ratio constraint has priority over limits + win.resize(400, 400) + app.processEvents() + vb.setLimits(xMin=0, xMax=10, yMin=0, yMax=10) + win.resize(800, 400) + app.processEvents() + w = vb.geometry().width() + h = vb.geometry().height() + view1 = QRectF(-5, 0, 20, 10) + size1 = QRectF(0, h, w, -h) + assertMapping(vb, view1, size1) + + +if __name__ == '__main__': + import user,sys + test_ViewBox() + \ No newline at end of file diff --git a/imageview/ImageView.py b/imageview/ImageView.py index c7c3206e..c9f421b4 100644 --- a/imageview/ImageView.py +++ b/imageview/ImageView.py @@ -12,8 +12,10 @@ Widget used for displaying 2D or 3D data. Features: - ROI plotting - Image normalization through a variety of methods """ -from ..Qt import QtCore, QtGui, USE_PYSIDE +import sys +import numpy as np +from ..Qt import QtCore, QtGui, USE_PYSIDE if USE_PYSIDE: from .ImageViewTemplate_pyside import * else: @@ -24,25 +26,14 @@ from ..graphicsItems.ROI import * from ..graphicsItems.LinearRegionItem import * from ..graphicsItems.InfiniteLine import * from ..graphicsItems.ViewBox import * -#from widgets import ROI -import sys -#from numpy import ndarray from .. import ptime as ptime -import numpy as np from .. import debug as debug - from ..SignalProxy import SignalProxy try: from bottleneck import nanmin, nanmax except ImportError: from numpy import nanmin, nanmax - -#try: - #from .. import metaarray as metaarray - #HAVE_METAARRAY = True -#except: - #HAVE_METAARRAY = False class PlotROI(ROI): @@ -72,6 +63,16 @@ class ImageView(QtGui.QWidget): imv = pg.ImageView() imv.show() imv.setImage(data) + + **Keyboard interaction** + + * left/right arrows step forward/backward 1 frame when pressed, + seek at 20fps when held. + * up/down arrows seek at 100fps + * pgup/pgdn seek at 1000fps + * home/end seek immediately to the first/last frame + * space begins playing frames. If time values (in seconds) are given + for each frame, then playback is in realtime. """ sigTimeChanged = QtCore.Signal(object, object) sigProcessingChanged = QtCore.Signal(object) @@ -79,8 +80,31 @@ class ImageView(QtGui.QWidget): def __init__(self, parent=None, name="ImageView", view=None, imageItem=None, *args): """ By default, this class creates an :class:`ImageItem ` to display image data - and a :class:`ViewBox ` to contain the ImageItem. Custom items may be given instead - by specifying the *view* and/or *imageItem* arguments. + and a :class:`ViewBox ` to contain the ImageItem. + + ============= ========================================================= + **Arguments** + parent (QWidget) Specifies the parent widget to which + this ImageView will belong. If None, then the ImageView + is created with no parent. + name (str) The name used to register both the internal ViewBox + and the PlotItem used to display ROI data. See the *name* + argument to :func:`ViewBox.__init__() + `. + view (ViewBox or PlotItem) If specified, this will be used + as the display area that contains the displayed image. + Any :class:`ViewBox `, + :class:`PlotItem `, or other + compatible object is acceptable. + imageItem (ImageItem) If specified, this object will be used to + display the image. Must be an instance of ImageItem + or other compatible object. + ============= ========================================================= + + Note: to display axis ticks inside the ImageView, instantiate it + with a PlotItem instance as its view:: + + pg.ImageView(view=pg.PlotItem()) """ QtGui.QWidget.__init__(self, parent, *args) self.levelMax = 4096 @@ -165,6 +189,7 @@ class ImageView(QtGui.QWidget): self.normRoi.sigRegionChangeFinished.connect(self.updateNorm) self.ui.roiPlot.registerPlot(self.name + '_ROI') + self.view.register(self.name) self.noRepeatKeys = [QtCore.Qt.Key_Right, QtCore.Qt.Key_Left, QtCore.Qt.Key_Up, QtCore.Qt.Key_Down, QtCore.Qt.Key_PageUp, QtCore.Qt.Key_PageDown] @@ -318,7 +343,7 @@ class ImageView(QtGui.QWidget): self.ui.histogram.setLevels(min, max) def autoRange(self): - """Auto scale and pan the view around the image.""" + """Auto scale and pan the view around the image such that the image fills the view.""" image = self.getProcessedImage() self.view.autoRange() diff --git a/opengl/MeshData.py b/opengl/MeshData.py index 34a6e3fc..5adf4b64 100644 --- a/opengl/MeshData.py +++ b/opengl/MeshData.py @@ -1,5 +1,5 @@ -from pyqtgraph.Qt import QtGui -import pyqtgraph.functions as fn +from ..Qt import QtGui +from .. import functions as fn import numpy as np class MeshData(object): @@ -501,4 +501,4 @@ class MeshData(object): faces[start+cols:start+(cols*2)] = rowtemplate2 + row * cols return MeshData(vertexes=verts, faces=faces) - \ No newline at end of file + diff --git a/opengl/items/GLImageItem.py b/opengl/items/GLImageItem.py index 2cab23a3..59ddaf6f 100644 --- a/opengl/items/GLImageItem.py +++ b/opengl/items/GLImageItem.py @@ -25,13 +25,21 @@ class GLImageItem(GLGraphicsItem): """ self.smooth = smooth - self.data = data + self._needUpdate = False GLGraphicsItem.__init__(self) + self.setData(data) self.setGLOptions(glOptions) def initializeGL(self): glEnable(GL_TEXTURE_2D) self.texture = glGenTextures(1) + + def setData(self, data): + self.data = data + self._needUpdate = True + self.update() + + def _updateTexture(self): glBindTexture(GL_TEXTURE_2D, self.texture) if self.smooth: glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) @@ -63,7 +71,8 @@ class GLImageItem(GLGraphicsItem): def paint(self): - + if self._needUpdate: + self._updateTexture() glEnable(GL_TEXTURE_2D) glBindTexture(GL_TEXTURE_2D, self.texture) diff --git a/opengl/items/GLScatterPlotItem.py b/opengl/items/GLScatterPlotItem.py index bb2c89a3..6cfcc6aa 100644 --- a/opengl/items/GLScatterPlotItem.py +++ b/opengl/items/GLScatterPlotItem.py @@ -59,7 +59,7 @@ class GLScatterPlotItem(GLGraphicsItem): w = 64 def fn(x,y): r = ((x-w/2.)**2 + (y-w/2.)**2) ** 0.5 - return 200 * (w/2. - np.clip(r, w/2.-1.0, w/2.)) + return 255 * (w/2. - np.clip(r, w/2.-1.0, w/2.)) pData = np.empty((w, w, 4)) pData[:] = 255 pData[:,:,3] = np.fromfunction(fn, pData.shape[:2]) diff --git a/opengl/items/GLVolumeItem.py b/opengl/items/GLVolumeItem.py index 84f23e12..cbe22db9 100644 --- a/opengl/items/GLVolumeItem.py +++ b/opengl/items/GLVolumeItem.py @@ -2,6 +2,7 @@ from OpenGL.GL import * from .. GLGraphicsItem import GLGraphicsItem from ...Qt import QtGui import numpy as np +from ... import debug __all__ = ['GLVolumeItem'] @@ -25,13 +26,22 @@ class GLVolumeItem(GLGraphicsItem): self.sliceDensity = sliceDensity self.smooth = smooth - self.data = data + self.data = None + self._needUpload = False + self.texture = None GLGraphicsItem.__init__(self) self.setGLOptions(glOptions) + self.setData(data) - def initializeGL(self): + def setData(self, data): + self.data = data + self._needUpload = True + self.update() + + def _uploadData(self): glEnable(GL_TEXTURE_3D) - self.texture = glGenTextures(1) + if self.texture is None: + self.texture = glGenTextures(1) glBindTexture(GL_TEXTURE_3D, self.texture) if self.smooth: glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) @@ -60,9 +70,16 @@ class GLVolumeItem(GLGraphicsItem): glNewList(l, GL_COMPILE) self.drawVolume(ax, d) glEndList() - - + + self._needUpload = False + def paint(self): + if self.data is None: + return + + if self._needUpload: + self._uploadData() + self.setupGLState() glEnable(GL_TEXTURE_3D) diff --git a/parametertree/ParameterTree.py b/parametertree/ParameterTree.py index 953f3bb7..ef7c1030 100644 --- a/parametertree/ParameterTree.py +++ b/parametertree/ParameterTree.py @@ -7,9 +7,16 @@ from .ParameterItem import ParameterItem class ParameterTree(TreeWidget): - """Widget used to display or control data from a ParameterSet""" + """Widget used to display or control data from a hierarchy of Parameters""" def __init__(self, parent=None, showHeader=True): + """ + ============== ======================================================== + **Arguments:** + parent (QWidget) An optional parent widget + showHeader (bool) If True, then the QTreeView header is displayed. + ============== ======================================================== + """ TreeWidget.__init__(self, parent) self.setVerticalScrollMode(self.ScrollPerPixel) self.setHorizontalScrollMode(self.ScrollPerPixel) @@ -25,10 +32,35 @@ class ParameterTree(TreeWidget): self.setRootIsDecorated(False) def setParameters(self, param, showTop=True): + """ + Set the top-level :class:`Parameter ` + to be displayed in this ParameterTree. + + If *showTop* is False, then the top-level parameter is hidden and only + its children will be visible. This is a convenience method equivalent + to:: + + tree.clear() + tree.addParameters(param, showTop) + """ self.clear() self.addParameters(param, showTop=showTop) def addParameters(self, param, root=None, depth=0, showTop=True): + """ + Adds one top-level :class:`Parameter ` + to the view. + + ============== ========================================================== + **Arguments:** + param The :class:`Parameter ` + to add. + root The item within the tree to which *param* should be added. + By default, *param* is added as a top-level item. + showTop If False, then *param* will be hidden, and only its + children will be visible in the tree. + ============== ========================================================== + """ item = param.makeTreeItem(depth=depth) if root is None: root = self.invisibleRootItem() @@ -45,11 +77,14 @@ class ParameterTree(TreeWidget): self.addParameters(ch, root=item, depth=depth+1) def clear(self): - self.invisibleRootItem().takeChildren() - + """ + Remove all parameters from the tree. + """ + self.invisibleRootItem().takeChildren() def focusNext(self, item, forward=True): - ## Give input focus to the next (or previous) item after 'item' + """Give input focus to the next (or previous) item after *item* + """ while True: parent = item.parent() if parent is None: diff --git a/parametertree/parameterTypes.py b/parametertree/parameterTypes.py index 1f3eb692..8aba4bca 100644 --- a/parametertree/parameterTypes.py +++ b/parametertree/parameterTypes.py @@ -125,6 +125,7 @@ class WidgetParameterItem(ParameterItem): w.sigChanged = w.toggled w.value = w.isChecked w.setValue = w.setChecked + w.setEnabled(not opts.get('readonly', False)) self.hideWidget = False elif t == 'str': w = QtGui.QLineEdit() @@ -140,6 +141,7 @@ class WidgetParameterItem(ParameterItem): w.setValue = w.setColor self.hideWidget = False w.setFlat(True) + w.setEnabled(not opts.get('readonly', False)) elif t == 'colormap': from ..widgets.GradientWidget import GradientWidget ## need this here to avoid import loop w = GradientWidget(orientation='bottom') @@ -274,6 +276,8 @@ class WidgetParameterItem(ParameterItem): if 'readonly' in opts: self.updateDefaultBtn() + if isinstance(self.widget, (QtGui.QCheckBox,ColorButton)): + w.setEnabled(not opts['readonly']) ## If widget is a SpinBox, pass options straight through if isinstance(self.widget, SpinBox): @@ -281,6 +285,9 @@ class WidgetParameterItem(ParameterItem): opts['suffix'] = opts['units'] self.widget.setOpts(**opts) self.updateDisplayLabel() + + + class EventProxy(QtCore.QObject): def __init__(self, qobj, callback): @@ -532,8 +539,8 @@ class ListParameter(Parameter): self.forward, self.reverse = self.mapping(limits) Parameter.setLimits(self, limits) - #print self.name(), self.value(), limits - if len(self.reverse) > 0 and self.value() not in self.reverse[0]: + #print self.name(), self.value(), limits, self.reverse + if len(self.reverse[0]) > 0 and self.value() not in self.reverse[0]: self.setValue(self.reverse[0][0]) #def addItem(self, name, value=None): @@ -636,6 +643,7 @@ class TextParameterItem(WidgetParameterItem): def makeWidget(self): self.textBox = QtGui.QTextEdit() self.textBox.setMaximumHeight(100) + self.textBox.setReadOnly(self.param.opts.get('readonly', False)) self.textBox.value = lambda: str(self.textBox.toPlainText()) self.textBox.setValue = self.textBox.setPlainText self.textBox.sigChanged = self.textBox.textChanged diff --git a/parametertree/tests/test_parametertypes.py b/parametertree/tests/test_parametertypes.py new file mode 100644 index 00000000..c7cd2cb3 --- /dev/null +++ b/parametertree/tests/test_parametertypes.py @@ -0,0 +1,18 @@ +import pyqtgraph.parametertree as pt +import pyqtgraph as pg +app = pg.mkQApp() + +def test_opts(): + paramSpec = [ + dict(name='bool', type='bool', readonly=True), + dict(name='color', type='color', readonly=True), + ] + + param = pt.Parameter.create(name='params', type='group', children=paramSpec) + tree = pt.ParameterTree() + tree.setParameters(param) + + assert param.param('bool').items.keys()[0].widget.isEnabled() is False + assert param.param('color').items.keys()[0].widget.isEnabled() is False + + diff --git a/tests/test_qt.py b/tests/test_qt.py index cef54777..729bf695 100644 --- a/tests/test_qt.py +++ b/tests/test_qt.py @@ -1,5 +1,7 @@ import pyqtgraph as pg -import gc +import gc, os + +app = pg.mkQApp() def test_isQObjectAlive(): o1 = pg.QtCore.QObject() @@ -8,3 +10,14 @@ def test_isQObjectAlive(): del o1 gc.collect() assert not pg.Qt.isQObjectAlive(o2) + + +def test_loadUiType(): + path = os.path.dirname(__file__) + formClass, baseClass = pg.Qt.loadUiType(os.path.join(path, 'uictest.ui')) + w = baseClass() + ui = formClass() + ui.setupUi(w) + w.show() + app.processEvents() + diff --git a/tests/test_ref_cycles.py b/tests/test_ref_cycles.py new file mode 100644 index 00000000..0284852c --- /dev/null +++ b/tests/test_ref_cycles.py @@ -0,0 +1,77 @@ +""" +Test for unwanted reference cycles + +""" +import pyqtgraph as pg +import numpy as np +import gc, weakref +app = pg.mkQApp() + +def assert_alldead(refs): + for ref in refs: + assert ref() is None + +def qObjectTree(root): + """Return root and its entire tree of qobject children""" + childs = [root] + for ch in pg.QtCore.QObject.children(root): + childs += qObjectTree(ch) + return childs + +def mkrefs(*objs): + """Return a list of weakrefs to each object in *objs. + QObject instances are expanded to include all child objects. + """ + allObjs = {} + for obj in objs: + if isinstance(obj, pg.QtCore.QObject): + obj = qObjectTree(obj) + else: + obj = [obj] + for o in obj: + allObjs[id(o)] = o + + return map(weakref.ref, allObjs.values()) + +def test_PlotWidget(): + def mkobjs(*args, **kwds): + w = pg.PlotWidget(*args, **kwds) + data = pg.np.array([1,5,2,4,3]) + c = w.plot(data, name='stuff') + w.addLegend() + + # test that connections do not keep objects alive + w.plotItem.vb.sigRangeChanged.connect(mkrefs) + app.focusChanged.connect(w.plotItem.vb.invertY) + + # return weakrefs to a bunch of objects that should die when the scope exits. + return mkrefs(w, c, data, w.plotItem, w.plotItem.vb, w.plotItem.getMenu(), w.plotItem.getAxis('left')) + + for i in range(5): + assert_alldead(mkobjs()) + +def test_ImageView(): + def mkobjs(): + iv = pg.ImageView() + data = np.zeros((10,10,5)) + iv.setImage(data) + + return mkrefs(iv, iv.imageItem, iv.view, iv.ui.histogram, data) + + for i in range(5): + assert_alldead(mkobjs()) + +def test_GraphicsWindow(): + def mkobjs(): + w = pg.GraphicsWindow() + p1 = w.addPlot() + v1 = w.addViewBox() + return mkrefs(w, p1, v1) + + for i in range(5): + assert_alldead(mkobjs()) + + + +if __name__ == '__main__': + ot = test_PlotItem() diff --git a/tests/test_stability.py b/tests/test_stability.py new file mode 100644 index 00000000..a64e30e4 --- /dev/null +++ b/tests/test_stability.py @@ -0,0 +1,160 @@ +""" +PyQt/PySide stress test: + +Create lots of random widgets and graphics items, connect them together randomly, +the tear them down repeatedly. + +The purpose of this is to attempt to generate segmentation faults. +""" +from PyQt4.QtTest import QTest +import pyqtgraph as pg +from random import seed, randint +import sys, gc, weakref + +app = pg.mkQApp() + +seed(12345) + +widgetTypes = [ + pg.PlotWidget, + pg.ImageView, + pg.GraphicsView, + pg.QtGui.QWidget, + pg.QtGui.QTreeWidget, + pg.QtGui.QPushButton, + ] + +itemTypes = [ + pg.PlotCurveItem, + pg.ImageItem, + pg.PlotDataItem, + pg.ViewBox, + pg.QtGui.QGraphicsRectItem + ] + +widgets = [] +items = [] +allWidgets = weakref.WeakSet() + + +def crashtest(): + global allWidgets + try: + gc.disable() + actions = [ + createWidget, + #setParent, + forgetWidget, + showWidget, + processEvents, + #raiseException, + #addReference, + ] + + thread = WorkThread() + thread.start() + + while True: + try: + action = randItem(actions) + action() + print('[%d widgets alive, %d zombie]' % (len(allWidgets), len(allWidgets) - len(widgets))) + except KeyboardInterrupt: + print("Caught interrupt; send another to exit.") + try: + for i in range(100): + QTest.qWait(100) + except KeyboardInterrupt: + thread.terminate() + break + except: + sys.excepthook(*sys.exc_info()) + finally: + gc.enable() + + + +class WorkThread(pg.QtCore.QThread): + '''Intended to give the gc an opportunity to run from a non-gui thread.''' + def run(self): + i = 0 + while True: + i += 1 + if (i % 1000000) == 0: + print('--worker--') + + +def randItem(items): + return items[randint(0, len(items)-1)] + +def p(msg): + print(msg) + sys.stdout.flush() + +def createWidget(): + p('create widget') + global widgets, allWidgets + if len(widgets) > 50: + return + widget = randItem(widgetTypes)() + widget.setWindowTitle(widget.__class__.__name__) + widgets.append(widget) + allWidgets.add(widget) + p(" %s" % widget) + return widget + +def setParent(): + p('set parent') + global widgets + if len(widgets) < 2: + return + child = parent = None + while child is parent: + child = randItem(widgets) + parent = randItem(widgets) + p(" %s parent of %s" % (parent, child)) + child.setParent(parent) + +def forgetWidget(): + p('forget widget') + global widgets + if len(widgets) < 1: + return + widget = randItem(widgets) + p(' %s' % widget) + widgets.remove(widget) + +def showWidget(): + p('show widget') + global widgets + if len(widgets) < 1: + return + widget = randItem(widgets) + p(' %s' % widget) + widget.show() + +def processEvents(): + p('process events') + QTest.qWait(25) + +class TstException(Exception): + pass + +def raiseException(): + p('raise exception') + raise TstException("A test exception") + +def addReference(): + p('add reference') + global widgets + if len(widgets) < 1: + return + obj1 = randItem(widgets) + obj2 = randItem(widgets) + p(' %s -> %s' % (obj1, obj2)) + obj1._testref = obj2 + + + +if __name__ == '__main__': + test_stability() \ No newline at end of file diff --git a/tests/uictest.ui b/tests/uictest.ui new file mode 100644 index 00000000..25d14f2b --- /dev/null +++ b/tests/uictest.ui @@ -0,0 +1,53 @@ + + + Form + + + + 0 + 0 + 400 + 300 + + + + Form + + + + + 10 + 10 + 120 + 80 + + + + + + + 10 + 110 + 120 + 80 + + + + + + + PlotWidget + QWidget +
pyqtgraph
+ 1 +
+ + ImageView + QWidget +
pyqtgraph
+ 1 +
+
+ + +
diff --git a/widgets/RemoteGraphicsView.py b/widgets/RemoteGraphicsView.py index cb9a7052..75ce90b0 100644 --- a/widgets/RemoteGraphicsView.py +++ b/widgets/RemoteGraphicsView.py @@ -108,7 +108,7 @@ class RemoteGraphicsView(QtGui.QWidget): return QtGui.QWidget.mouseMoveEvent(self, ev) def wheelEvent(self, ev): - self._view.wheelEvent(ev.pos(), ev.globalPos(), ev.delta(), int(ev.buttons()), int(ev.modifiers()), ev.orientation(), _callSync='off') + self._view.wheelEvent(ev.pos(), ev.globalPos(), ev.delta(), int(ev.buttons()), int(ev.modifiers()), int(ev.orientation()), _callSync='off') ev.accept() return QtGui.QWidget.wheelEvent(self, ev) @@ -243,6 +243,7 @@ class Renderer(GraphicsView): def wheelEvent(self, pos, gpos, d, btns, mods, ori): btns = QtCore.Qt.MouseButtons(btns) mods = QtCore.Qt.KeyboardModifiers(mods) + ori = (None, QtCore.Qt.Horizontal, QtCore.Qt.Vertical)[ori] return GraphicsView.wheelEvent(self, QtGui.QWheelEvent(pos, gpos, d, btns, mods, ori)) def keyEvent(self, typ, mods, text, autorep, count):