From 5a08650853a339c383281fd531fe059b74e1bbcd Mon Sep 17 00:00:00 2001 From: Ogi Moore Date: Sat, 10 Apr 2021 22:42:44 -0700 Subject: [PATCH] Improve target item - incorporate bits from PR 313 (#1318) Overhaul TargetItem based on @lesauxvi 's PR #313 --- .flake8 | 46 +- doc/source/graphicsItems/index.rst | 1 + doc/source/graphicsItems/targetitem.rst | 17 + examples/InfiniteLine.py | 47 ++ examples/utils.py | 1 + pyqtgraph/__init__.py | 3 +- pyqtgraph/graphicsItems/ScatterPlotItem.py | 14 +- pyqtgraph/graphicsItems/TargetItem.py | 545 +++++++++++++++++---- 8 files changed, 528 insertions(+), 146 deletions(-) create mode 100644 doc/source/graphicsItems/targetitem.rst diff --git a/.flake8 b/.flake8 index 0556c925..d988c302 100644 --- a/.flake8 +++ b/.flake8 @@ -3,47 +3,5 @@ exclude = .git,.tox,__pycache__,doc,old,build,dist show_source = True statistics = True verbose = 2 -select = - E101, - E112, - E122, - E125, - E133, - E223, - E224, - E242, - E273, - E274, - E901, - E902, - W191, - W601, - W602, - W603, - W604, - E124, - E231, - E211, - E261, - E271, - E272, - E304, - F401, - F402, - F403, - F404, - E501, - E502, - E702, - E703, - E711, - E712, - E721, - F811, - F812, - F821, - F822, - F823, - F831, - F841, - W292 +max-line-length = 88 +extend-ignore = E203, W503 \ No newline at end of file diff --git a/doc/source/graphicsItems/index.rst b/doc/source/graphicsItems/index.rst index 9b24f30f..54bf12f2 100644 --- a/doc/source/graphicsItems/index.rst +++ b/doc/source/graphicsItems/index.rst @@ -46,3 +46,4 @@ Contents: uigraphicsitem graphicswidgetanchor dateaxisitem + targetitem diff --git a/doc/source/graphicsItems/targetitem.rst b/doc/source/graphicsItems/targetitem.rst new file mode 100644 index 00000000..a21e6c8f --- /dev/null +++ b/doc/source/graphicsItems/targetitem.rst @@ -0,0 +1,17 @@ +TargetItem +========== + +.. autoclass:: pyqtgraph.TargetItem + :members: + + .. automethod:: pyqtgraph.TargetItem.__init__ + + +TargetLabel +================== + +.. autoclass:: pyqtgraph.TargetLabel + :members: + + .. automethod:: pyqtgraph.TargetLabel.__init__ + \ No newline at end of file diff --git a/examples/InfiniteLine.py b/examples/InfiniteLine.py index d9ef8a9a..07216be2 100644 --- a/examples/InfiniteLine.py +++ b/examples/InfiniteLine.py @@ -32,6 +32,53 @@ p1.addItem(inf1) p1.addItem(inf2) p1.addItem(inf3) +targetItem1 = pg.TargetItem( + label=True, + symbol="crosshair", + labelOpts={ + "angle": 0 + } +) + +targetItem2 = pg.TargetItem( + pos=(30, 5), + size=20, + label="vert={1:0.2f}", + symbol="star", + pen="#F4511E", + labelOpts={ + "angle": 45, + "offset": QtCore.QPoint(15, 15) + } +) + +targetItem3 = pg.TargetItem( + pos=(10, 10), + size=10, + label="Third Label", + symbol="x", + pen="#00ACC1", + labelOpts={ + "anchor": QtCore.QPointF(0.5, 0.5), + "offset": QtCore.QPointF(30, 0), + "color": "#558B2F", + "rotateAxis": (0, 1) + } +) + +def callableFunction(x, y): + return f"Square Values: ({x**2:.4f}, {y**2:.4f})" + +targetItem4 = pg.TargetItem( + pos=(10, -10), + label=callableFunction +) + +p1.addItem(targetItem1) +p1.addItem(targetItem2) +p1.addItem(targetItem3) +p1.addItem(targetItem4) + # Add a linear region with a label lr = pg.LinearRegionItem(values=[70, 80]) p1.addItem(lr) diff --git a/examples/utils.py b/examples/utils.py index 38c92a03..fe5e0fee 100644 --- a/examples/utils.py +++ b/examples/utils.py @@ -32,6 +32,7 @@ examples = OrderedDict([ ('GraphicsItems', OrderedDict([ ('Scatter Plot', 'ScatterPlot.py'), #('PlotItem', 'PlotItem.py'), + ('InfiniteLine', 'InfiniteLine.py'), ('IsocurveItem', 'isocurve.py'), ('GraphItem', 'GraphItem.py'), ('ErrorBarItem', 'ErrorBarItem.py'), diff --git a/pyqtgraph/__init__.py b/pyqtgraph/__init__.py index 88960abe..bc96e720 100644 --- a/pyqtgraph/__init__.py +++ b/pyqtgraph/__init__.py @@ -231,7 +231,8 @@ from .graphicsItems.LinearRegionItem import * from .graphicsItems.FillBetweenItem import * from .graphicsItems.LegendItem import * from .graphicsItems.ScatterPlotItem import * -from .graphicsItems.ItemGroup import * +from .graphicsItems.ItemGroup import * +from .graphicsItems.TargetItem import * from .widgets.MultiPlotWidget import * from .widgets.ScatterPlotWidget import * diff --git a/pyqtgraph/graphicsItems/ScatterPlotItem.py b/pyqtgraph/graphicsItems/ScatterPlotItem.py index 3d0b9f15..3d35f8fc 100644 --- a/pyqtgraph/graphicsItems/ScatterPlotItem.py +++ b/pyqtgraph/graphicsItems/ScatterPlotItem.py @@ -39,10 +39,22 @@ _USE_QRECT = QT_LIB not in ['PySide2', 'PySide6'] ## Build all symbol paths name_list = ['o', 's', 't', 't1', 't2', 't3', 'd', '+', 'x', 'p', 'h', 'star', - 'arrow_up', 'arrow_right', 'arrow_down', 'arrow_left'] + 'arrow_up', 'arrow_right', 'arrow_down', 'arrow_left', 'crosshair'] Symbols = OrderedDict([(name, QtGui.QPainterPath()) for name in name_list]) Symbols['o'].addEllipse(QtCore.QRectF(-0.5, -0.5, 1, 1)) Symbols['s'].addRect(QtCore.QRectF(-0.5, -0.5, 1, 1)) + +def makeCrosshair(r=0.5, w=1, h=1): + path = QtGui.QPainterPath() + rect = QtCore.QRectF(-r, -r, r * 2, r * 2) + path.addEllipse(rect) + path.moveTo(-w, 0) + path.lineTo(w, 0) + path.moveTo(0, -h) + path.lineTo(0, h) + return path +Symbols['crosshair'] = makeCrosshair() + coords = { 't': [(-0.5, -0.5), (0, 0.5), (0.5, -0.5)], 't1': [(-0.5, 0.5), (0, -0.5), (0.5, 0.5)], diff --git a/pyqtgraph/graphicsItems/TargetItem.py b/pyqtgraph/graphicsItems/TargetItem.py index 782e8a3b..b599f33b 100644 --- a/pyqtgraph/graphicsItems/TargetItem.py +++ b/pyqtgraph/graphicsItems/TargetItem.py @@ -1,132 +1,477 @@ +from math import atan2, pi + from ..Qt import QtGui, QtCore -import numpy as np from ..Point import Point from .. import functions as fn from .GraphicsObject import GraphicsObject +from .UIGraphicsItem import UIGraphicsItem from .TextItem import TextItem +from .ScatterPlotItem import Symbols, makeCrosshair +from .ViewBox import ViewBox +import string +import warnings -class TargetItem(GraphicsObject): +class TargetItem(UIGraphicsItem): """Draws a draggable target symbol (circle plus crosshair). The size of TargetItem will remain fixed on screen even as the view is zoomed. Includes an optional text label. """ - sigDragged = QtCore.Signal(object) - def __init__(self, movable=True, radii=(5, 10, 10), pen=(255, 255, 0), brush=(0, 0, 255, 100)): - GraphicsObject.__init__(self) - self._bounds = None - self._radii = radii - self._picture = None + sigPositionChanged = QtCore.Signal(object) + sigPositionChangeFinished = QtCore.Signal(object) + + def __init__( + self, + pos=None, + size=10, + radii=None, + symbol="crosshair", + pen=None, + hoverPen=None, + brush=None, + hoverBrush=None, + movable=True, + label=None, + labelOpts=None, + ): + r""" + Parameters + ---------- + pos : list, tuple, QPointF, QPoint, Optional + Initial position of the symbol. Default is (0, 0) + size : int + Size of the symbol in pixels. Default is 10. + radii : tuple of int + Deprecated. Gives size of crosshair in screen pixels. + pen : QPen, tuple, list or str + Pen to use when drawing line. Can be any arguments that are valid + for :func:`~pyqtgraph.mkPen`. Default pen is transparent yellow. + brush : QBrush, tuple, list, or str + Defines the brush that fill the symbol. Can be any arguments that + is valid for :func:`~pyqtgraph.mkBrush`. Default is transparent + blue. + movable : bool + If True, the symbol can be dragged to a new position by the user. + hoverPen : QPen, tuple, list, or str + Pen to use when drawing symbol when hovering over it. Can be any + arguments that are valid for :func:`~pyqtgraph.mkPen`. Default pen + is red. + hoverBrush : QBrush, tuple, list or str + Brush to use to fill the symbol when hovering over it. Can be any + arguments that is valid for :func:`~pyqtgraph.mkBrush`. Default is + transparent blue. + symbol : QPainterPath or str + QPainterPath to use for drawing the target, should be centered at + ``(0, 0)`` with ``max(width, height) == 1.0``. Alternatively a string + which can be any symbol accepted by + :func:`~pyqtgraph.ScatterPlotItem.setData` + label : bool, str or callable, optional + Text to be displayed in a label attached to the symbol, or None to + show no label (default is None). May optionally include formatting + strings to display the symbol value, or a callable that accepts x + and y as inputs. If True, the label is ``x = {: >.3n}\ny = {: >.3n}`` + False or None will result in no text being displayed + labelOpts : dict + A dict of keyword arguments to use when constructing the text + label. See :class:`TargetLabel` and :class:`~pyqtgraph.TextItem` + """ + super().__init__(self) self.movable = movable self.moving = False - self.label = None - self.labelAngle = 0 - self.pen = fn.mkPen(pen) - self.brush = fn.mkBrush(brush) + self._label = None + self.mouseHovering = False - def setLabel(self, label): - if label is None: - if self.label is not None: - self.label.scene().removeItem(self.label) - self.label = None + if radii is not None: + warnings.warn( + "'radii' is now deprecated, and will be removed in 0.13.0. Use 'size' " + "parameter instead", + DeprecationWarning, + stacklevel=2, + ) + symbol = makeCrosshair(*radii) + size = 1 + + if pen is None: + pen = (255, 255, 0) + self.setPen(pen) + + if hoverPen is None: + hoverPen = (255, 0, 255) + self.setHoverPen(hoverPen) + + if brush is None: + brush = (0, 0, 255, 50) + self.setBrush(brush) + + if hoverBrush is None: + hoverBrush = (0, 255, 255, 100) + self.setHoverBrush(hoverBrush) + + self.currentPen = self.pen + self.currentBrush = self.brush + + self._shape = None + + self._pos = Point(0, 0) + if pos is None: + pos = Point(0, 0) + self.setPos(pos) + + if isinstance(symbol, str): + try: + self._path = Symbols[symbol] + except KeyError: + raise KeyError("symbol name found in available Symbols") + elif isinstance(symbol, QtGui.QPainterPath): + self._path = symbol else: - if self.label is None: - self.label = TextItem() - self.label.setParentItem(self) - self.label.setText(label) - self._updateLabel() + raise TypeError("Unknown type provided as symbol") - def setLabelAngle(self, angle): - if self.labelAngle != angle: - self.labelAngle = angle - self._updateLabel() + self.scale = size + self.setPath(self._path) + self.setLabel(label, labelOpts) + + @property + def sigDragged(self): + warnings.warn( + "'sigDragged' has been deprecated and will be removed in 0.13.0. Use " + "`sigPositionChanged` instead", + DeprecationWarning, + stacklevel=2, + ) + return self.sigPositionChangeFinished + + def setPos(self, pos): + """Method to set the position to ``(x, y)`` within the plot view + + Parameters + ---------- + pos : tuple, list, QPointF, QPoint, or pg.Point + Container that consists of ``(x, y)`` representation of where the + TargetItem should be placed + + Raises + ------ + TypeError + If the type of ``pos`` does not match the known types to extract + coordinate info from, a TypeError is raised + """ + if isinstance(pos, Point): + newPos = pos + elif isinstance(pos, (tuple, list)): + newPos = Point(pos) + elif isinstance(pos, (QtCore.QPointF, QtCore.QPoint)): + newPos = Point(pos.x(), pos.y()) + else: + raise TypeError + if self._pos != newPos: + self._pos = newPos + super().setPos(self._pos) + self.sigPositionChanged.emit(self) + + def setBrush(self, *args, **kwargs): + """Set the brush that fills the symbol. Allowable arguments are any that + are valid for :func:`~pyqtgraph.mkBrush`. + """ + self.brush = fn.mkBrush(*args, **kwargs) + if not self.mouseHovering: + self.currentBrush = self.brush + self.update() + + def setHoverBrush(self, *args, **kwargs): + """Set the brush that fills the symbol when hovering over it. Allowable + arguments are any that are valid for :func:`~pyqtgraph.mkBrush`. + """ + self.hoverBrush = fn.mkBrush(*args, **kwargs) + if self.mouseHovering: + self.currentBrush = self.hoverBrush + self.update() + + def setPen(self, *args, **kwargs): + """Set the pen for drawing the symbol. Allowable arguments are any that + are valid for :func:`~pyqtgraph.mkPen`.""" + self.pen = fn.mkPen(*args, **kwargs) + if not self.mouseHovering: + self.currentPen = self.pen + self.update() + + def setHoverPen(self, *args, **kwargs): + """Set the pen for drawing the symbol when hovering over it. Allowable + arguments are any that are valid for + :func:`~pyqtgraph.mkPen`.""" + self.hoverPen = fn.mkPen(*args, **kwargs) + if self.mouseHovering: + self.currentPen = self.hoverPen + self.update() def boundingRect(self): - if self._picture is None: - self._drawPicture() - return self._bounds - - def dataBounds(self, axis, frac=1.0, orthoRange=None): - return [0, 0] + return self.shape().boundingRect() - def viewTransformChanged(self): - self._picture = None - self.prepareGeometryChange() - self._updateLabel() + def paint(self, p, *_): + p.setPen(self.currentPen) + p.setBrush(self.currentBrush) + p.drawPath(self.shape()) - def _updateLabel(self): - if self.label is None: - return + def setPath(self, path): + if path != self._path: + self._path = path + self._shape = None + return None - # find an optimal location for text at the given angle - angle = self.labelAngle * np.pi / 180. - lbr = self.label.boundingRect() - center = lbr.center() - a = abs(np.sin(angle) * lbr.height()*0.5) - b = abs(np.cos(angle) * lbr.width()*0.5) - r = max(self._radii) + 2 + max(a, b) - pos = self.mapFromScene(self.mapToScene(QtCore.QPointF(0, 0)) + r * QtCore.QPointF(np.cos(angle), -np.sin(angle)) - center) - self.label.setPos(pos) + def shape(self): + if self._shape is None: + s = self.generateShape() + if s is None: + return self._path + self._shape = s - def paint(self, p, *args): - if self._picture is None: - self._drawPicture() - self._picture.play(p) + # beware--this can cause the view to adjust + # which would immediately invalidate the shape. + self.prepareGeometryChange() + return self._shape - def _drawPicture(self): - self._picture = QtGui.QPicture() - p = QtGui.QPainter(self._picture) - p.setRenderHint(p.Antialiasing) - - # Note: could do this with self.pixelLength, but this is faster. - o = self.mapToScene(QtCore.QPointF(0, 0)) - dx = (self.mapToScene(QtCore.QPointF(1, 0)) - o).x() - dy = (self.mapToScene(QtCore.QPointF(0, 1)) - o).y() - if dx == 0 or dy == 0: - p.end() - self._bounds = QtCore.QRectF() - return - px = abs(1.0 / dx) - py = abs(1.0 / dy) - - r, w, h = self._radii - w = w * px - h = h * py - rx = r * px - ry = r * py - rect = QtCore.QRectF(-rx, -ry, rx*2, ry*2) - p.setPen(self.pen) - p.setBrush(self.brush) - p.drawEllipse(rect) - p.drawLine(Point(-w, 0), Point(w, 0)) - p.drawLine(Point(0, -h), Point(0, h)) - p.end() - - bx = max(w, rx) - by = max(h, ry) - self._bounds = QtCore.QRectF(-bx, -by, bx*2, by*2) + def generateShape(self): + dt = self.deviceTransform() + if dt is None: + self._shape = self._path + return None + v = dt.map(QtCore.QPointF(1, 0)) - dt.map(QtCore.QPointF(0, 0)) + dti = fn.invertQTransform(dt) + devPos = dt.map(QtCore.QPointF(0, 0)) + tr = QtGui.QTransform() + tr.translate(devPos.x(), devPos.y()) + va = atan2(v.y(), v.x()) + tr.rotate(va * 180.0 / pi) + tr.scale(self.scale, self.scale) + return dti.map(tr.map(self._path)) def mouseDragEvent(self, ev): - if not self.movable: + if not self.movable or int(ev.button() & QtCore.Qt.LeftButton) == 0: return - if ev.button() == QtCore.Qt.LeftButton: - if ev.isStart(): - self.moving = True - self.cursorOffset = self.pos() - self.mapToParent(ev.buttonDownPos()) - self.startPosition = self.pos() + ev.accept() + if ev.isStart(): + self.symbolOffset = self.pos() - self.mapToView(ev.buttonDownPos()) + self.moving = True + + if not self.moving: + return + self.setPos(self.symbolOffset + self.mapToView(ev.pos())) + + if ev.isFinish(): + self.moving = False + self.sigPositionChangeFinished.emit(self) + + def mouseClickEvent(self, ev): + if self.moving and ev.button() == QtCore.Qt.RightButton: ev.accept() - - if not self.moving: - return - - self.setPos(self.cursorOffset + self.mapToParent(ev.pos())) - if ev.isFinish(): - self.moving = False - self.sigDragged.emit(self) + self.moving = False + self.sigPositionChanged.emit(self) + self.sigPositionChangeFinished.emit(self) + + def setMouseHover(self, hover): + # Inform the item that the mouse is(not) hovering over it + if self.mouseHovering is hover: + return + self.mouseHovering = hover + if hover: + self.currentBrush = self.hoverBrush + self.currentPen = self.hoverPen + else: + self.currentBrush = self.brush + self.currentPen = self.pen + self.update() def hoverEvent(self, ev): - if self.movable: - ev.acceptDrags(QtCore.Qt.LeftButton) + if self.movable and (not ev.isExit()) and ev.acceptDrags(QtCore.Qt.LeftButton): + self.setMouseHover(True) + else: + self.setMouseHover(False) + def viewTransformChanged(self): + GraphicsObject.viewTransformChanged(self) + self._shape = None # invalidate shape, recompute later if requested. + self.update() + + def pos(self): + """Provides the current position of the TargetItem + + Returns + ------- + Point + pg.Point of the current position of the TargetItem + """ + return self._pos + + def label(self): + """Provides the TargetLabel if it exists + + Returns + ------- + TargetLabel or None + If a TargetLabel exists for this TargetItem, return that, otherwise + return None + """ + return self._label + + def setLabel(self, text=None, labelOpts=None): + """Method to call to enable or disable the TargetLabel for displaying text + + Parameters + ---------- + text : Callable or str, optional + Details how to format the text, by default None + If None, do not show any text next to the TargetItem + If Callable, then the label will display the result of ``text(x, y)`` + If a fromatted string, then the output of ``text.format(x, y)`` will be + displayed + If a non-formatted string, then the text label will display ``text``, by + default None + labelOpts : dictionary, optional + These arguments are passed on to :class:`~pyqtgraph.TextItem` + """ + if not text: + if self._label is not None and self._label.scene() is not None: + # remove the label if it's already added + self._label.scene().removeItem(self._label) + self._label = None + else: + # provide default text if text is True + if text is True: + # convert to default value or empty string + text = "x = {: .3n}\ny = {: .3n}" + + labelOpts = {} if labelOpts is None else labelOpts + if self._label is not None: + self._label.scene().removeItem(self._label) + self._label = TargetLabel(self, text=text, **labelOpts) + + def setLabelAngle(self, angle): + warnings.warn( + "TargetItem.setLabelAngle is deprecated and will be removed in 0.13.0." + "Use TargetItem.label().setAngle() instead", + DeprecationWarning, + stacklevel=2, + ) + if self.label() is not None and angle != self.label().angle(): + self.label().setAngle(angle) + return None + + +class TargetLabel(TextItem): + """A TextItem that attaches itself to a TargetItem. + + This class extends TextItem with the following features : + * Automatically positions adjacent to the symbol at a fixed position. + * Automatically reformats text when the symbol location has changed. + + Parameters + ---------- + target : TargetItem + The TargetItem to which this label will be attached to. + text : str or callable, Optional + Governs the text displayed, can be a fixed string or a format string + that accepts the x, and y position of the target item; or be a callable + method that accepts a tuple (x, y) and returns a string to be displayed. + If None, an empty string is used. Default is None + offset : tuple or list or QPointF or QPoint + Position to set the anchor of the TargetLabel away from the center of + the target in pixels, by default it is (20, 0). + anchor : tuple, list, QPointF or QPoint + Position to rotate the TargetLabel about, and position to set the + offset value to see :class:`~pyqtgraph.TextItem` for more inforation. + kwargs : dict of arguments that are passed on to + :class:`~pyqtgraph.TextItem` constructor, excluding text parameter + """ + + def __init__( + self, + target, + text="", + offset=(20, 0), + anchor=(0, 0.5), + **kwargs, + ): + super().__init__(anchor=anchor, **kwargs) + self.setParentItem(target) + self.target = target + self.setFormat(text) + + if isinstance(offset, Point): + self.offset = offset + elif isinstance(offset, (tuple, list)): + self.offset = Point(*offset) + elif isinstance(offset, (QtCore.QPoint, QtCore.QPointF)): + self.offset = Point(offset.x(), offset.y()) + else: + raise TypeError("Offset parameter is the wrong data type") + self.target.sigPositionChanged.connect(self.valueChanged) + self.valueChanged() + + def format(self): + return self._format + + def setFormat(self, text): + """Method to set how the TargetLabel should display the text. This + method should be called from TargetItem.setLabel directly. + + Parameters + ---------- + text : Callable or str + Details how to format the text. + If Callable, then the label will display the result of ``text(x, y)`` + If a fromatted string, then the output of ``text.format(x, y)`` will be + displayed + If a non-formatted string, then the text label will display ``text`` + """ + if not callable(text): + parsed = list(string.Formatter().parse(text)) + if parsed and parsed[0][1] is not None: + self.setProperty("formattableText", True) + else: + self.setText(text) + self.setProperty("formattableText", False) + else: + self.setProperty("formattableText", False) + self._format = text + self.valueChanged() + + def valueChanged(self): + x, y = self.target.pos() + if self.property("formattableText"): + self.setText(self._format.format(float(x), float(y))) + elif callable(self._format): + self.setText(self._format(x, y)) + + def viewTransformChanged(self): + viewbox = self.getViewBox() + if isinstance(viewbox, ViewBox): + viewPixelSize = viewbox.viewPixelSize() + scaledOffset = QtCore.QPointF( + self.offset.x() * viewPixelSize[0], self.offset.y() * viewPixelSize[1] + ) + self.setPos(scaledOffset) + return super().viewTransformChanged() + + def mouseClickEvent(self, ev): + return self.parentItem().mouseClickEvent(ev) + + def mouseDragEvent(self, ev): + targetItem = self.parentItem() + if not targetItem.movable or int(ev.button() & QtCore.Qt.LeftButton) == 0: + return + ev.accept() + if ev.isStart(): + targetItem.symbolOffset = targetItem.pos() - self.mapToView( + ev.buttonDownPos() + ) + targetItem.moving = True + + if not targetItem.moving: + return + targetItem.setPos(targetItem.symbolOffset + self.mapToView(ev.pos())) + + if ev.isFinish(): + targetItem.moving = False + targetItem.sigPositionChangeFinished.emit(self)