class LinkCurveItem(QGraphicsPathItem): """ Link curve item. The main component of a :class:`LinkItem`. """ def __init__(self, parent): # type: (QGraphicsItem) -> None super().__init__(parent) self.setAcceptedMouseButtons(Qt.NoButton) self.setAcceptHoverEvents(True) self.__animationEnabled = False self.__hover = False self.__enabled = True self.__shape = None # type: Optional[QPainterPath] self.__curvepath = QPainterPath() self.__curvepath_disabled = None # type: Optional[QPainterPath] self.__pen = self.pen() self.setPen(QPen(QBrush(QColor("#9CACB4")), 2.0)) self.shadow = QGraphicsDropShadowEffect(blurRadius=5, color=QColor(SHADOW_COLOR), offset=QPointF(0, 0)) self.setGraphicsEffect(self.shadow) self.shadow.setEnabled(False) self.__blurAnimation = QPropertyAnimation(self.shadow, b"blurRadius") self.__blurAnimation.setDuration(50) self.__blurAnimation.finished.connect(self.__on_finished) def setCurvePath(self, path): # type: (QPainterPath) -> None if path != self.__curvepath: self.prepareGeometryChange() self.__curvepath = QPainterPath(path) self.__curvepath_disabled = None self.__shape = None self.__update() def curvePath(self): # type: () -> QPainterPath return QPainterPath(self.__curvepath) def setHoverState(self, state): # type: (bool) -> None self.prepareGeometryChange() self.__hover = state self.__update() def setLinkEnabled(self, state): # type: (bool) -> None self.prepareGeometryChange() self.__enabled = state self.__update() def isLinkEnabled(self): # type: () -> bool return self.__enabled def setPen(self, pen): # type: (QPen) -> None if self.__pen != pen: self.prepareGeometryChange() self.__pen = QPen(pen) self.__shape = None super().setPen(self.__pen) def shape(self): # type: () -> QPainterPath if self.__shape is None: path = self.curvePath() pen = QPen(self.pen()) pen.setWidthF(max(pen.widthF(), 25.0)) pen.setStyle(Qt.SolidLine) self.__shape = stroke_path(path, pen) return self.__shape def setPath(self, path): # type: (QPainterPath) -> None self.__shape = None super().setPath(path) def setAnimationEnabled(self, enabled): # type: (bool) -> None """ Set the link item animation enabled. """ if self.__animationEnabled != enabled: self.__animationEnabled = enabled def __update(self): # type: () -> None radius = 5 if self.__hover else 0 if radius != 0 and not self.shadow.isEnabled(): self.shadow.setEnabled(True) if self.__animationEnabled: if self.__blurAnimation.state() == QPropertyAnimation.Running: self.__blurAnimation.pause() self.__blurAnimation.setStartValue(self.shadow.blurRadius()) self.__blurAnimation.setEndValue(radius) self.__blurAnimation.start() else: self.shadow.setBlurRadius(radius) basecurve = self.__curvepath link_enabled = self.__enabled if link_enabled: path = basecurve else: if self.__curvepath_disabled is None: self.__curvepath_disabled = path_link_disabled(basecurve) path = self.__curvepath_disabled self.setPath(path) def __on_finished(self): if self.shadow.blurRadius() == 0: self.shadow.setEnabled(False)
class SplitterResizer(QObject): """ An object able to control the size of a widget in a QSplitter instance. """ def __init__(self, parent=None, **kwargs): # type: (Optional[QObject], Any) -> None super().__init__(parent, **kwargs) self.__splitter = None # type: Optional[QSplitter] self.__widget = None # type: Optional[QWidget] self.__updateOnShow = True # Need __update on next show event self.__animationEnabled = True self.__size = -1 self.__expanded = False self.__animation = QPropertyAnimation( self, b"size_", self, duration=200 ) self.__action = QAction("toggle-expanded", self, checkable=True) self.__action.triggered[bool].connect(self.setExpanded) def setSize(self, size): # type: (int) -> None """ Set the size of the controlled widget (either width or height depending on the orientation). .. note:: The controlled widget's size is only updated when it it is shown. """ if self.__size != size: self.__size = size self.__update() def size(self): # type: () -> int """ Return the size of the widget in the splitter (either height of width) depending on the splitter orientation. """ if self.__splitter and self.__widget: index = self.__splitter.indexOf(self.__widget) sizes = self.__splitter.sizes() return sizes[index] else: return -1 size_ = Property(int, fget=size, fset=setSize) def setAnimationEnabled(self, enable): # type: (bool) -> None """Enable/disable animation.""" self.__animation.setDuration(0 if enable else 200) def animationEnabled(self): # type: () -> bool return self.__animation.duration() == 0 def setSplitterAndWidget(self, splitter, widget): # type: (QSplitter, QWidget) -> None """Set the QSplitter and QWidget instance the resizer should control. .. note:: the widget must be in the splitter. """ if splitter and widget and not splitter.indexOf(widget) > 0: raise ValueError("Widget must be in a splitter.") if self.__widget is not None: self.__widget.removeEventFilter(self) if self.__splitter is not None: self.__splitter.removeEventFilter(self) self.__splitter = splitter self.__widget = widget if widget is not None: widget.installEventFilter(self) if splitter is not None: splitter.installEventFilter(self) self.__update() size = self.size() if self.__expanded and size == 0: self.open() elif not self.__expanded and size > 0: self.close() def toggleExpandedAction(self): # type: () -> QAction """Return a QAction that can be used to toggle expanded state. """ return self.__action def toogleExpandedAction(self): warnings.warn( "'toogleExpandedAction is deprecated, use 'toggleExpandedAction' " "instead.", DeprecationWarning, stacklevel=2 ) return self.toggleExpandedAction() def open(self): # type: () -> None """Open the controlled widget (expand it to sizeHint). """ self.__expanded = True self.__action.setChecked(True) if self.__splitter is None or self.__widget is None: return hint = self.__widget.sizeHint() if self.__splitter.orientation() == Qt.Vertical: end = hint.height() else: end = hint.width() self.__animation.setStartValue(0) self.__animation.setEndValue(end) self.__animation.start() def close(self): # type: () -> None """Close the controlled widget (shrink to size 0). """ self.__expanded = False self.__action.setChecked(False) if self.__splitter is None or self.__widget is None: return self.__animation.setStartValue(self.size()) self.__animation.setEndValue(0) self.__animation.start() def setExpanded(self, expanded): # type: (bool) -> None """Set the expanded state.""" if self.__expanded != expanded: if expanded: self.open() else: self.close() def expanded(self): # type: () -> bool """Return the expanded state.""" return self.__expanded def __update(self): # type: () -> None """Update the splitter sizes.""" if self.__splitter and self.__widget: if sum(self.__splitter.sizes()) == 0: # schedule update on next show event self.__updateOnShow = True return splitter = self.__splitter index = splitter.indexOf(self.__widget) sizes = splitter.sizes() current = sizes[index] diff = current - self.__size sizes[index] = self.__size sizes[index - 1] = sizes[index - 1] + diff self.__splitter.setSizes(sizes) def eventFilter(self, obj, event): # type: (QObject, QEvent) -> bool if event.type() == QEvent.Resize and obj is self.__widget and \ self.__animation.state() == QPropertyAnimation.Stopped: # Update the expanded state when the user opens/closes the widget # by dragging the splitter handle. assert self.__splitter is not None assert isinstance(event, QResizeEvent) if self.__splitter.orientation() == Qt.Vertical: size = event.size().height() else: size = event.size().width() if self.__expanded and size == 0: self.__action.setChecked(False) self.__expanded = False elif not self.__expanded and size > 0: self.__action.setChecked(True) self.__expanded = True if event.type() == QEvent.Show and obj is self.__splitter and \ self.__updateOnShow: # Update the splitter state after receiving valid geometry self.__updateOnShow = False self.__update() return super().eventFilter(obj, event)
class NodeBodyItem(GraphicsPathObject): """ The central part (body) of the `NodeItem`. """ def __init__(self, parent=None): GraphicsPathObject.__init__(self, parent) assert (isinstance(parent, NodeItem)) self.__processingState = 0 self.__progress = -1 self.__animationEnabled = False self.__isSelected = False self.__hasFocus = False self.__hover = False self.__shapeRect = QRectF(-10, -10, 20, 20) self.setAcceptHoverEvents(True) self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, True) self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True) self.setPen(QPen(Qt.NoPen)) self.setPalette(default_palette()) self.shadow = QGraphicsDropShadowEffect( blurRadius=3, color=QColor(SHADOW_COLOR), offset=QPointF(0, 0), ) self.shadow.setEnabled(True) # An item with the same shape as this object, stacked behind this # item as a source for QGraphicsDropShadowEffect. Cannot attach # the effect to this item directly as QGraphicsEffect makes the item # non devicePixelRatio aware. shadowitem = GraphicsPathObject(self, objectName="shadow-shape-item") shadowitem.setPen(Qt.NoPen) shadowitem.setBrush(QBrush(QColor(SHADOW_COLOR).lighter())) shadowitem.setGraphicsEffect(self.shadow) shadowitem.setFlag(QGraphicsItem.ItemStacksBehindParent) self.__shadow = shadowitem self.__blurAnimation = QPropertyAnimation(self.shadow, b"blurRadius", self) self.__blurAnimation.setDuration(100) self.__blurAnimation.finished.connect(self.__on_finished) self.__pingAnimation = QPropertyAnimation(self, b"scale", self) self.__pingAnimation.setDuration(250) self.__pingAnimation.setKeyValues([(0.0, 1.0), (0.5, 1.1), (1.0, 1.0)]) # TODO: The body item should allow the setting of arbitrary painter # paths (for instance rounded rect, ...) def setShapeRect(self, rect): """ Set the item's shape `rect`. The item should be confined within this rect. """ path = QPainterPath() path.addEllipse(rect) self.setPath(path) self.__shadow.setPath(path) self.__shapeRect = rect def setPalette(self, palette): """ Set the body color palette (:class:`QPalette`). """ self.palette = palette self.__updateBrush() def setAnimationEnabled(self, enabled): """ Set the node animation enabled. """ if self.__animationEnabled != enabled: self.__animationEnabled = enabled def setProcessingState(self, state): """ Set the processing state of the node. """ if self.__processingState != state: self.__processingState = state if not state and self.__animationEnabled: self.ping() def setProgress(self, progress): """ Set the progress indicator state of the node. `progress` should be a number between 0 and 100. """ self.__progress = progress self.update() def ping(self): """ Trigger a 'ping' animation. """ animation_restart(self.__pingAnimation) def hoverEnterEvent(self, event): self.__hover = True self.__updateShadowState() return GraphicsPathObject.hoverEnterEvent(self, event) def hoverLeaveEvent(self, event): self.__hover = False self.__updateShadowState() return GraphicsPathObject.hoverLeaveEvent(self, event) def paint(self, painter, option, widget): """ Paint the shape and a progress meter. """ # Let the default implementation draw the shape if option.state & QStyle.State_Selected: # Prevent the default bounding rect selection indicator. option.state = option.state ^ QStyle.State_Selected GraphicsPathObject.paint(self, painter, option, widget) if self.__progress >= 0: # Draw the progress meter over the shape. # Set the clip to shape so the meter does not overflow the shape. painter.save() painter.setClipPath(self.shape(), Qt.ReplaceClip) color = self.palette.color(QPalette.ButtonText) pen = QPen(color, 5) painter.setPen(pen) painter.setRenderHints(QPainter.Antialiasing) span = max(1, int(self.__progress * 57.60)) painter.drawArc(self.__shapeRect, 90 * 16, -span) painter.restore() def __updateShadowState(self): if self.__hasFocus: color = QColor(FOCUS_OUTLINE_COLOR) self.setPen(QPen(color, 1.5)) else: self.setPen(QPen(Qt.NoPen)) radius = 3 enabled = False if self.__isSelected: enabled = True radius = 7 if self.__hover: radius = 17 enabled = True if enabled and not self.shadow.isEnabled(): self.shadow.setEnabled(enabled) if self.__animationEnabled: if self.__blurAnimation.state() == QPropertyAnimation.Running: self.__blurAnimation.pause() self.__blurAnimation.setStartValue(self.shadow.blurRadius()) self.__blurAnimation.setEndValue(radius) self.__blurAnimation.start() else: self.shadow.setBlurRadius(radius) def __updateBrush(self): palette = self.palette if self.__isSelected: cg = QPalette.Active else: cg = QPalette.Inactive palette.setCurrentColorGroup(cg) c1 = palette.color(QPalette.Light) c2 = palette.color(QPalette.Button) grad = radial_gradient(c2, c1) self.setBrush(QBrush(grad)) # TODO: The selected and focus states should be set using the # QStyle flags (State_Selected. State_HasFocus) def setSelected(self, selected): """ Set the `selected` state. .. note:: The item does not have `QGraphicsItem.ItemIsSelectable` flag. This property is instead controlled by the parent NodeItem. """ self.__isSelected = selected self.__updateBrush() def setHasFocus(self, focus): """ Set the `has focus` state. .. note:: The item does not have `QGraphicsItem.ItemIsFocusable` flag. This property is instead controlled by the parent NodeItem. """ self.__hasFocus = focus self.__updateShadowState() def __on_finished(self): if self.shadow.blurRadius() == 0: self.shadow.setEnabled(False)
class NodeBodyItem(GraphicsPathObject): """ The central part (body) of the `NodeItem`. """ def __init__(self, parent=None): GraphicsPathObject.__init__(self, parent) assert isinstance(parent, NodeItem) self.__processingState = 0 self.__progress = -1 self.__animationEnabled = False self.__isSelected = False self.__hasFocus = False self.__hover = False self.__shapeRect = QRectF(-10, -10, 20, 20) self.setAcceptHoverEvents(True) self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, True) self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True) self.setPen(QPen(Qt.NoPen)) self.setPalette(default_palette()) self.shadow = QGraphicsDropShadowEffect( blurRadius=3, color=QColor(SHADOW_COLOR), offset=QPointF(0, 0), ) self.shadow.setEnabled(True) # An item with the same shape as this object, stacked behind this # item as a source for QGraphicsDropShadowEffect. Cannot attach # the effect to this item directly as QGraphicsEffect makes the item # non devicePixelRatio aware. shadowitem = GraphicsPathObject(self, objectName="shadow-shape-item") shadowitem.setPen(Qt.NoPen) shadowitem.setBrush(QBrush(QColor(SHADOW_COLOR).lighter())) shadowitem.setGraphicsEffect(self.shadow) shadowitem.setFlag(QGraphicsItem.ItemStacksBehindParent) self.__shadow = shadowitem self.__blurAnimation = QPropertyAnimation(self.shadow, b"blurRadius", self) self.__blurAnimation.setDuration(100) self.__blurAnimation.finished.connect(self.__on_finished) self.__pingAnimation = QPropertyAnimation(self, b"scale", self) self.__pingAnimation.setDuration(250) self.__pingAnimation.setKeyValues([(0.0, 1.0), (0.5, 1.1), (1.0, 1.0)]) # TODO: The body item should allow the setting of arbitrary painter # paths (for instance rounded rect, ...) def setShapeRect(self, rect): """ Set the item's shape `rect`. The item should be confined within this rect. """ path = QPainterPath() path.addEllipse(rect) self.setPath(path) self.__shadow.setPath(path) self.__shapeRect = rect def setPalette(self, palette): """ Set the body color palette (:class:`QPalette`). """ self.palette = palette self.__updateBrush() def setAnimationEnabled(self, enabled): """ Set the node animation enabled. """ if self.__animationEnabled != enabled: self.__animationEnabled = enabled def setProcessingState(self, state): """ Set the processing state of the node. """ if self.__processingState != state: self.__processingState = state if not state and self.__animationEnabled: self.ping() def setProgress(self, progress): """ Set the progress indicator state of the node. `progress` should be a number between 0 and 100. """ self.__progress = progress self.update() def ping(self): """ Trigger a 'ping' animation. """ animation_restart(self.__pingAnimation) def hoverEnterEvent(self, event): self.__hover = True self.__updateShadowState() return GraphicsPathObject.hoverEnterEvent(self, event) def hoverLeaveEvent(self, event): self.__hover = False self.__updateShadowState() return GraphicsPathObject.hoverLeaveEvent(self, event) def paint(self, painter, option, widget): """ Paint the shape and a progress meter. """ # Let the default implementation draw the shape if option.state & QStyle.State_Selected: # Prevent the default bounding rect selection indicator. option.state = option.state ^ QStyle.State_Selected GraphicsPathObject.paint(self, painter, option, widget) if self.__progress >= 0: # Draw the progress meter over the shape. # Set the clip to shape so the meter does not overflow the shape. painter.save() painter.setClipPath(self.shape(), Qt.ReplaceClip) color = self.palette.color(QPalette.ButtonText) pen = QPen(color, 5) painter.setPen(pen) painter.setRenderHints(QPainter.Antialiasing) span = max(1, int(self.__progress * 57.60)) painter.drawArc(self.__shapeRect, 90 * 16, -span) painter.restore() def __updateShadowState(self): if self.__hasFocus: color = QColor(FOCUS_OUTLINE_COLOR) self.setPen(QPen(color, 1.5)) else: self.setPen(QPen(Qt.NoPen)) radius = 3 enabled = False if self.__isSelected: enabled = True radius = 7 if self.__hover: radius = 17 enabled = True if enabled and not self.shadow.isEnabled(): self.shadow.setEnabled(enabled) if self.__animationEnabled: if self.__blurAnimation.state() == QPropertyAnimation.Running: self.__blurAnimation.pause() self.__blurAnimation.setStartValue(self.shadow.blurRadius()) self.__blurAnimation.setEndValue(radius) self.__blurAnimation.start() else: self.shadow.setBlurRadius(radius) def __updateBrush(self): palette = self.palette if self.__isSelected: cg = QPalette.Active else: cg = QPalette.Inactive palette.setCurrentColorGroup(cg) c1 = palette.color(QPalette.Light) c2 = palette.color(QPalette.Button) grad = radial_gradient(c2, c1) self.setBrush(QBrush(grad)) # TODO: The selected and focus states should be set using the # QStyle flags (State_Selected. State_HasFocus) def setSelected(self, selected): """ Set the `selected` state. .. note:: The item does not have `QGraphicsItem.ItemIsSelectable` flag. This property is instead controlled by the parent NodeItem. """ self.__isSelected = selected self.__updateBrush() def setHasFocus(self, focus): """ Set the `has focus` state. .. note:: The item does not have `QGraphicsItem.ItemIsFocusable` flag. This property is instead controlled by the parent NodeItem. """ self.__hasFocus = focus self.__updateShadowState() def __on_finished(self): if self.shadow.blurRadius() == 0: self.shadow.setEnabled(False)
class SplitterResizer(QObject): """ An object able to control the size of a widget in a QSplitter instance. """ def __init__(self, parent=None): super().__init__(parent) self.__splitter = None self.__widget = None self.__updateOnShow = True # Need __update on next show event self.__animationEnabled = True self.__size = -1 self.__expanded = False self.__animation = QPropertyAnimation( self, b"size_", self, duration=200 ) self.__action = QAction("toogle-expanded", self, checkable=True) self.__action.triggered[bool].connect(self.setExpanded) def setSize(self, size): """Set the size of the controlled widget (either width or height depending on the orientation). .. note:: The controlled widget's size is only updated when it it is shown. """ if self.__size != size: self.__size = size self.__update() def size(self): """Return the size of the widget in the splitter (either height of width) depending on the splitter orientation. """ if self.__splitter and self.__widget: index = self.__splitter.indexOf(self.__widget) sizes = self.__splitter.sizes() return sizes[index] else: return -1 size_ = Property(int, fget=size, fset=setSize) def setAnimationEnabled(self, enable): """Enable/disable animation. """ self.__animation.setDuration(0 if enable else 200) def animationEnabled(self): return self.__animation.duration() == 0 def setSplitterAndWidget(self, splitter, widget): """Set the QSplitter and QWidget instance the resizer should control. .. note:: the widget must be in the splitter. """ if splitter and widget and not splitter.indexOf(widget) > 0: raise ValueError("Widget must be in a spliter.") if self.__widget is not None: self.__widget.removeEventFilter(self) if self.__splitter is not None: self.__splitter.removeEventFilter(self) self.__splitter = splitter self.__widget = widget if widget is not None: widget.installEventFilter(self) if splitter is not None: splitter.installEventFilter(self) self.__update() size = self.size() if self.__expanded and size == 0: self.open() elif not self.__expanded and size > 0: self.close() def toogleExpandedAction(self): """Return a QAction that can be used to toggle expanded state. """ return self.__action def open(self): """Open the controlled widget (expand it to sizeHint). """ self.__expanded = True self.__action.setChecked(True) if self.__splitter is None or self.__widget is None: return hint = self.__widget.sizeHint() if self.__splitter.orientation() == Qt.Vertical: end = hint.height() else: end = hint.width() self.__animation.setStartValue(0) self.__animation.setEndValue(end) self.__animation.start() def close(self): """Close the controlled widget (shrink to size 0). """ self.__expanded = False self.__action.setChecked(False) if self.__splitter is None or self.__widget is None: return self.__animation.setStartValue(self.size()) self.__animation.setEndValue(0) self.__animation.start() def setExpanded(self, expanded): """Set the expanded state. """ if self.__expanded != expanded: if expanded: self.open() else: self.close() def expanded(self): """Return the expanded state. """ return self.__expanded def __update(self): """Update the splitter sizes. """ if self.__splitter and self.__widget: if sum(self.__splitter.sizes()) == 0: # schedule update on next show event self.__updateOnShow = True return splitter = self.__splitter index = splitter.indexOf(self.__widget) sizes = splitter.sizes() current = sizes[index] diff = current - self.__size sizes[index] = self.__size sizes[index - 1] = sizes[index - 1] + diff self.__splitter.setSizes(sizes) def eventFilter(self, obj, event): if event.type() == QEvent.Resize and obj is self.__widget and \ self.__animation.state() == QPropertyAnimation.Stopped: # Update the expanded state when the user opens/closes the widget # by dragging the splitter handle. if self.__splitter.orientation() == Qt.Vertical: size = event.size().height() else: size = event.size().width() if self.__expanded and size == 0: self.__action.setChecked(False) self.__expanded = False elif not self.__expanded and size > 0: self.__action.setChecked(True) self.__expanded = True if event.type() == QEvent.Show and obj is self.__splitter and \ self.__updateOnShow: # Update the splitter state after receiving valid geometry self.__updateOnShow = False self.__update() return super().eventFilter(obj, event)
class NodeAnchorItem(GraphicsPathObject): """ The left/right widget input/output anchors. """ def __init__(self, parent, **kwargs): # type: (Optional[QGraphicsItem], Any) -> None super().__init__(parent, **kwargs) self.__parentNodeItem = None # type: Optional[NodeItem] self.setAcceptHoverEvents(True) self.setPen(QPen(Qt.NoPen)) self.normalBrush = QBrush(QColor("#CDD5D9")) self.normalHoverBrush = QBrush(QColor("#9CACB4")) self.connectedBrush = self.normalHoverBrush self.connectedHoverBrush = QBrush(QColor("#959595")) self.setBrush(self.normalBrush) self.__animationEnabled = False self.__hover = False # Does this item have any anchored links. self.anchored = False if isinstance(parent, NodeItem): self.__parentNodeItem = parent else: self.__parentNodeItem = None self.__anchorPath = QPainterPath() self.__points = [] # type: List[AnchorPoint] self.__pointPositions = [] # type: List[float] self.__fullStroke = QPainterPath() self.__dottedStroke = QPainterPath() self.__shape = None # type: Optional[QPainterPath] self.shadow = QGraphicsDropShadowEffect( blurRadius=0, color=QColor(SHADOW_COLOR), offset=QPointF(0, 0), ) # self.setGraphicsEffect(self.shadow) self.shadow.setEnabled(False) shadowitem = GraphicsPathObject(self, objectName="shadow-shape-item") shadowitem.setPen(Qt.NoPen) shadowitem.setBrush(QBrush(QColor(SHADOW_COLOR))) shadowitem.setGraphicsEffect(self.shadow) shadowitem.setFlag(QGraphicsItem.ItemStacksBehindParent) self.__shadow = shadowitem self.__blurAnimation = QPropertyAnimation(self.shadow, b"blurRadius", self) self.__blurAnimation.setDuration(50) self.__blurAnimation.finished.connect(self.__on_finished) def parentNodeItem(self): # type: () -> Optional['NodeItem'] """ Return a parent :class:`NodeItem` or ``None`` if this anchor's parent is not a :class:`NodeItem` instance. """ return self.__parentNodeItem def setAnchorPath(self, path): # type: (QPainterPath) -> None """ Set the anchor's curve path as a :class:`QPainterPath`. """ self.__anchorPath = QPainterPath(path) # Create a stroke of the path. stroke_path = QPainterPathStroker() stroke_path.setCapStyle(Qt.RoundCap) # Shape is wider (bigger mouse hit area - should be settable) stroke_path.setWidth(25) self.prepareGeometryChange() self.__shape = stroke_path.createStroke(path) # The full stroke stroke_path.setWidth(3) self.__fullStroke = stroke_path.createStroke(path) # The dotted stroke (when not connected to anything) stroke_path.setDashPattern(Qt.DotLine) self.__dottedStroke = stroke_path.createStroke(path) if self.anchored: assert self.__fullStroke is not None self.setPath(self.__fullStroke) self.__shadow.setPath(self.__fullStroke) brush = self.connectedHoverBrush if self.__hover else self.connectedBrush self.setBrush(brush) else: assert self.__dottedStroke is not None self.setPath(self.__dottedStroke) self.__shadow.setPath(self.__dottedStroke) brush = self.normalHoverBrush if self.__hover else self.normalBrush self.setBrush(brush) def anchorPath(self): # type: () -> QPainterPath """ Return the anchor path (:class:`QPainterPath`). This is a curve on which the anchor points lie. """ return QPainterPath(self.__anchorPath) def setAnchored(self, anchored): # type: (bool) -> None """ Set the items anchored state. When ``False`` the item draws it self with a dotted stroke. """ self.anchored = anchored if anchored: self.setPath(self.__fullStroke) self.__shadow.setPath(self.__fullStroke) hover = self.__hover and len( self.__points) > 1 # a stylistic choice brush = self.connectedHoverBrush if hover else self.connectedBrush self.setBrush(brush) else: self.setPath(self.__dottedStroke) self.__shadow.setPath(self.__dottedStroke) brush = self.normalHoverBrush if self.__hover else self.normalBrush self.setBrush(brush) def setConnectionHint(self, hint=None): """ Set the connection hint. This can be used to indicate if a connection can be made or not. """ raise NotImplementedError def count(self): # type: () -> int """ Return the number of anchor points. """ return len(self.__points) def addAnchor(self, anchor, position=0.5): # type: (AnchorPoint, float) -> int """ Add a new :class:`AnchorPoint` to this item and return it's index. The `position` specifies where along the `anchorPath` is the new point inserted. """ return self.insertAnchor(self.count(), anchor, position) def insertAnchor(self, index, anchor, position=0.5): # type: (int, AnchorPoint, float) -> int """ Insert a new :class:`AnchorPoint` at `index`. See also -------- NodeAnchorItem.addAnchor """ if anchor in self.__points: raise ValueError("%s already added." % anchor) self.__points.insert(index, anchor) self.__pointPositions.insert(index, position) anchor.setParentItem(self) anchor.setPos(self.__anchorPath.pointAtPercent(position)) anchor.destroyed.connect(self.__onAnchorDestroyed) self.__updatePositions() self.setAnchored(bool(self.__points)) hover = self.__hover and len(self.__points) > 1 # a stylistic choice anchor.setHoverState(hover) return index def removeAnchor(self, anchor): # type: (AnchorPoint) -> None """ Remove and delete the anchor point. """ anchor = self.takeAnchor(anchor) anchor.hide() anchor.setParentItem(None) anchor.deleteLater() def takeAnchor(self, anchor): # type: (AnchorPoint) -> AnchorPoint """ Remove the anchor but don't delete it. """ index = self.__points.index(anchor) del self.__points[index] del self.__pointPositions[index] anchor.destroyed.disconnect(self.__onAnchorDestroyed) self.__updatePositions() self.setAnchored(bool(self.__points)) return anchor def __onAnchorDestroyed(self, anchor): # type: (QObject) -> None try: index = self.__points.index(anchor) except ValueError: return del self.__points[index] del self.__pointPositions[index] def anchorPoints(self): # type: () -> List[AnchorPoint] """ Return a list of anchor points. """ return list(self.__points) def anchorPoint(self, index): # type: (int) -> AnchorPoint """ Return the anchor point at `index`. """ return self.__points[index] def setAnchorPositions(self, positions): # type: (Iterable[float]) -> None """ Set the anchor positions in percentages (0..1) along the path curve. """ if self.__pointPositions != positions: self.__pointPositions = list(positions) self.__updatePositions() def anchorPositions(self): # type: () -> List[float] """ Return the positions of anchor points as a list of floats where each float is between 0 and 1 and specifies where along the anchor path does the point lie (0 is at start 1 is at the end). """ return list(self.__pointPositions) def shape(self): # type: () -> QPainterPath if self.__shape is not None: return QPainterPath(self.__shape) else: return super().shape() def boundingRect(self): if self.__shape is not None: return self.__shape.controlPointRect() else: return GraphicsPathObject.boundingRect(self) def hoverEnterEvent(self, event): self.__hover = True brush = self.connectedHoverBrush if self.anchored else self.normalHoverBrush self.setBrush(brush) self.__updateShadowState() return super().hoverEnterEvent(event) def hoverLeaveEvent(self, event): self.__hover = False brush = self.connectedBrush if self.anchored else self.normalBrush self.setBrush(brush) self.__updateShadowState() return super().hoverLeaveEvent(event) def setAnimationEnabled(self, enabled): # type: (bool) -> None """ Set the anchor animation enabled. """ if self.__animationEnabled != enabled: self.__animationEnabled = enabled def __updateShadowState(self): # type: () -> None radius = 5 if self.__hover else 0 if radius != 0 and not self.shadow.isEnabled(): self.shadow.setEnabled(True) if self.__animationEnabled: if self.__blurAnimation.state() == QPropertyAnimation.Running: self.__blurAnimation.pause() self.__blurAnimation.setStartValue(self.shadow.blurRadius()) self.__blurAnimation.setEndValue(radius) self.__blurAnimation.start() else: self.shadow.setBlurRadius(radius) for anchor in self.anchorPoints(): anchor.setHoverState(self.__hover) def __updatePositions(self): # type: () -> None """Update anchor points positions. """ for point, t in zip(self.__points, self.__pointPositions): pos = self.__anchorPath.pointAtPercent(t) point.setPos(pos) def __on_finished(self): # type: () -> None if self.shadow.blurRadius() == 0: self.shadow.setEnabled(False)
class LinkItem(QGraphicsWidget): """ A Link item in the canvas that connects two :class:`.NodeItem`\\s in the canvas. The link curve connects two `Anchor` items (see :func:`setSourceItem` and :func:`setSinkItem`). Once the anchors are set the curve automatically adjusts its end points whenever the anchors move. An optional source/sink text item can be displayed above the curve's central point (:func:`setSourceName`, :func:`setSinkName`) """ #: Signal emitted when the item has been activated (double-click) activated = Signal() #: Signal emitted the the item's selection state changes. selectedChanged = Signal(bool) #: Z value of the item Z_VALUE = 0 #: Runtime link state value #: These are pulled from SchemeLink.State for ease of binding to it's #: state State = SchemeLink.State #: The link has no associated state. NoState = SchemeLink.NoState #: Link is empty; the source node does not have any value on output Empty = SchemeLink.Empty #: Link is active; the source node has a valid value on output Active = SchemeLink.Active #: The link is pending; the sink node is scheduled for update Pending = SchemeLink.Pending #: The link's input is marked as invalidated (not yet available). Invalidated = SchemeLink.Invalidated def __init__(self, parent=None, **kwargs): # type: (Optional[QGraphicsItem], Any) -> None self.__boundingRect = None # type: Optional[QRectF] super().__init__(parent, **kwargs) self.setAcceptedMouseButtons(Qt.RightButton | Qt.LeftButton) self.setAcceptHoverEvents(True) self.__animationEnabled = False self.setZValue(self.Z_VALUE) self.sourceItem = None # type: Optional[NodeItem] self.sourceAnchor = None # type: Optional[AnchorPoint] self.sinkItem = None # type: Optional[NodeItem] self.sinkAnchor = None # type: Optional[AnchorPoint] self.curveItem = LinkCurveItem(self) self.linkTextItem = GraphicsTextItem(self) self.linkTextItem.setAcceptedMouseButtons(Qt.NoButton) self.linkTextItem.setAcceptHoverEvents(False) self.__sourceName = "" self.__sinkName = "" self.__dynamic = False self.__dynamicEnabled = False self.__state = LinkItem.NoState self.__channelNamesVisible = True self.hover = False self.channelNameAnim = QPropertyAnimation(self.linkTextItem, b'opacity', self) self.channelNameAnim.setDuration(50) self.prepareGeometryChange() self.__updatePen() self.__updatePalette() self.__updateFont() def setSourceItem(self, item, signal=None, anchor=None): # type: (Optional[NodeItem], Optional[OutputSignal], Optional[AnchorPoint]) -> None """ Set the source `item` (:class:`.NodeItem`). Use `anchor` (:class:`.AnchorPoint`) as the curve start point (if ``None`` a new output anchor will be created using ``item.newOutputAnchor()``). Setting item to ``None`` and a valid anchor is a valid operation (for instance while mouse dragging one end of the link). """ if item is not None and anchor is not None: if anchor not in item.outputAnchors(): raise ValueError("Anchor must be belong to the item") if self.sourceItem != item: if self.sourceAnchor: # Remove a previous source item and the corresponding anchor self.sourceAnchor.scenePositionChanged.disconnect( self._sourcePosChanged) if self.sourceItem is not None: self.sourceItem.removeOutputAnchor(self.sourceAnchor) self.sourceItem.selectedChanged.disconnect( self.__updateSelectedState) self.sourceItem = self.sourceAnchor = None self.sourceItem = item if item is not None and anchor is None: # Create a new output anchor for the item if none is provided. anchor = item.newOutputAnchor(signal) if item is not None: item.selectedChanged.connect(self.__updateSelectedState) if anchor != self.sourceAnchor: if self.sourceAnchor is not None: self.sourceAnchor.scenePositionChanged.disconnect( self._sourcePosChanged) self.sourceAnchor = anchor if self.sourceAnchor is not None: self.sourceAnchor.scenePositionChanged.connect( self._sourcePosChanged) self.__updateCurve() def setSinkItem(self, item, signal=None, anchor=None): # type: (Optional[NodeItem], Optional[InputSignal], Optional[AnchorPoint]) -> None """ Set the sink `item` (:class:`.NodeItem`). Use `anchor` (:class:`.AnchorPoint`) as the curve end point (if ``None`` a new input anchor will be created using ``item.newInputAnchor()``). Setting item to ``None`` and a valid anchor is a valid operation (for instance while mouse dragging one and of the link). """ if item is not None and anchor is not None: if anchor not in item.inputAnchors(): raise ValueError("Anchor must be belong to the item") if self.sinkItem != item: if self.sinkAnchor: # Remove a previous source item and the corresponding anchor self.sinkAnchor.scenePositionChanged.disconnect( self._sinkPosChanged) if self.sinkItem is not None: self.sinkItem.removeInputAnchor(self.sinkAnchor) self.sinkItem.selectedChanged.disconnect( self.__updateSelectedState) self.sinkItem = self.sinkAnchor = None self.sinkItem = item if item is not None and anchor is None: # Create a new input anchor for the item if none is provided. anchor = item.newInputAnchor(signal) if item is not None: item.selectedChanged.connect(self.__updateSelectedState) if self.sinkAnchor != anchor: if self.sinkAnchor is not None: self.sinkAnchor.scenePositionChanged.disconnect( self._sinkPosChanged) self.sinkAnchor = anchor if self.sinkAnchor is not None: self.sinkAnchor.scenePositionChanged.connect( self._sinkPosChanged) self.__updateCurve() def setChannelNamesVisible(self, visible): # type: (bool) -> None """ Set the visibility of the channel name text. """ if self.__channelNamesVisible != visible: self.__channelNamesVisible = visible self.__initChannelNameOpacity() def setSourceName(self, name): # type: (str) -> None """ Set the name of the source (used in channel name text). """ if self.__sourceName != name: self.__sourceName = name self.__updateText() def sourceName(self): # type: () -> str """ Return the source name. """ return self.__sourceName def setSinkName(self, name): # type: (str) -> None """ Set the name of the sink (used in channel name text). """ if self.__sinkName != name: self.__sinkName = name self.__updateText() def sinkName(self): # type: () -> str """ Return the sink name. """ return self.__sinkName def setAnimationEnabled(self, enabled): # type: (bool) -> None """ Set the link item animation enabled state. """ if self.__animationEnabled != enabled: self.__animationEnabled = enabled self.curveItem.setAnimationEnabled(enabled) def _sinkPosChanged(self, *arg): self.__updateCurve() def _sourcePosChanged(self, *arg): self.__updateCurve() def __updateCurve(self): # type: () -> None self.prepareGeometryChange() self.__boundingRect = None if self.sourceAnchor and self.sinkAnchor: source_pos = self.sourceAnchor.anchorScenePos() sink_pos = self.sinkAnchor.anchorScenePos() source_pos = self.curveItem.mapFromScene(source_pos) sink_pos = self.curveItem.mapFromScene(sink_pos) # Adaptive offset for the curve control points to avoid a # cusp when the two points have the same y coordinate # and are close together delta = source_pos - sink_pos dist = math.sqrt(delta.x()**2 + delta.y()**2) cp_offset = min(dist / 2.0, 60.0) # TODO: make the curve tangent orthogonal to the anchors path. path = QPainterPath() path.moveTo(source_pos) path.cubicTo(source_pos + QPointF(cp_offset, 0), sink_pos - QPointF(cp_offset, 0), sink_pos) self.curveItem.setCurvePath(path) self.__updateText() else: self.setHoverState(False) self.curveItem.setPath(QPainterPath()) def __updateText(self): # type: () -> None self.prepareGeometryChange() self.__boundingRect = None if self.__sourceName or self.__sinkName: if self.__sourceName != self.__sinkName: text = ("<nobr>{0}</nobr> \u2192 <nobr>{1}</nobr>".format( escape(self.__sourceName), escape(self.__sinkName))) else: # If the names are the same show only one. # Is this right? If the sink has two input channels of the # same type having the name on the link help elucidate # the scheme. text = escape(self.__sourceName) else: text = "" self.linkTextItem.setHtml( '<div align="center" style="font-size: small" >{0}</div>'.format( text)) path = self.curveItem.curvePath() # Constrain the text width if it is too long to fit on a single line # between the two ends if not path.isEmpty(): # Use the distance between the start/end points as a measure of # available space diff = path.pointAtPercent(0.0) - path.pointAtPercent(1.0) available_width = math.sqrt(diff.x()**2 + diff.y()**2) # Get the ideal text width if it was unconstrained doc = self.linkTextItem.document().clone(self) doc.setTextWidth(-1) idealwidth = doc.idealWidth() doc.deleteLater() # Constrain the text width but not below a certain min width minwidth = 100 textwidth = max(minwidth, min(available_width, idealwidth)) self.linkTextItem.setTextWidth(textwidth) else: # Reset the fixed width self.linkTextItem.setTextWidth(-1) if not path.isEmpty(): center = path.pointAtPercent(0.5) angle = path.angleAtPercent(0.5) brect = self.linkTextItem.boundingRect() transform = QTransform() transform.translate(center.x(), center.y()) # Rotate text to be on top of link if 90 <= angle < 270: transform.rotate(180 - angle) else: transform.rotate(-angle) # Center and move above the curve path. transform.translate(-brect.width() / 2, -brect.height()) self.linkTextItem.setTransform(transform) def removeLink(self): # type: () -> None self.setSinkItem(None) self.setSourceItem(None) self.__updateCurve() def setHoverState(self, state): # type: (bool) -> None if self.hover != state: self.prepareGeometryChange() self.__boundingRect = None self.hover = state if self.sinkAnchor: self.sinkAnchor.setHoverState(state) if self.sourceAnchor: self.sourceAnchor.setHoverState(state) self.curveItem.setHoverState(state) self.__updatePen() self.__updateChannelNameVisibility() self.__updateZValue() def __updateZValue(self): text_ss = self.linkTextItem.styleState() if self.hover: text_ss |= QStyle.State_HasFocus z = 9999 self.linkTextItem.setParentItem(None) else: text_ss &= ~QStyle.State_HasFocus z = self.Z_VALUE self.linkTextItem.setParentItem(self) self.linkTextItem.setZValue(z) self.linkTextItem.setStyleState(text_ss) def mouseDoubleClickEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> None super().mouseDoubleClickEvent(event) QTimer.singleShot(0, self.activated.emit) def hoverEnterEvent(self, event): # type: (QGraphicsSceneHoverEvent) -> None # Hover enter event happens when the mouse enters any child object # but we only want to show the 'hovered' shadow when the mouse # is over the 'curveItem', so we install self as an event filter # on the LinkCurveItem and listen to its hover events. self.curveItem.installSceneEventFilter(self) return super().hoverEnterEvent(event) def hoverLeaveEvent(self, event): # type: (QGraphicsSceneHoverEvent) -> None # Remove the event filter to prevent unnecessary work in # scene event filter when not needed self.curveItem.removeSceneEventFilter(self) return super().hoverLeaveEvent(event) def __initChannelNameOpacity(self): if self.__channelNamesVisible: self.linkTextItem.setOpacity(1) else: self.linkTextItem.setOpacity(0) def __updateChannelNameVisibility(self): if self.__channelNamesVisible: return enabled = self.hover or self.isSelected() or self.__isSelectedImplicit( ) targetOpacity = 1 if enabled else 0 if not self.__animationEnabled: self.linkTextItem.setOpacity(targetOpacity) else: if self.channelNameAnim.state() == QPropertyAnimation.Running: self.channelNameAnim.stop() self.channelNameAnim.setStartValue(self.linkTextItem.opacity()) self.channelNameAnim.setEndValue(targetOpacity) self.channelNameAnim.start() def changeEvent(self, event): # type: (QEvent) -> None if event.type() == QEvent.PaletteChange: self.__updatePalette() elif event.type() == QEvent.FontChange: self.__updateFont() super().changeEvent(event) def sceneEventFilter(self, obj, event): # type: (QGraphicsItem, QEvent) -> bool if obj is self.curveItem: if event.type() == QEvent.GraphicsSceneHoverEnter: self.setHoverState(True) elif event.type() == QEvent.GraphicsSceneHoverLeave: self.setHoverState(False) return super().sceneEventFilter(obj, event) def boundingRect(self): # type: () -> QRectF if self.__boundingRect is None: self.__boundingRect = self.childrenBoundingRect() return self.__boundingRect def shape(self): # type: () -> QPainterPath return self.curveItem.shape() def setEnabled(self, enabled): # type: (bool) -> None """ Reimplemented from :class:`QGraphicWidget` Set link enabled state. When disabled the link is rendered with a dashed line. """ # This getter/setter pair override a property from the base class. # They should be renamed to e.g. setLinkEnabled/linkEnabled self.curveItem.setLinkEnabled(enabled) def isEnabled(self): # type: () -> bool return self.curveItem.isLinkEnabled() def setDynamicEnabled(self, enabled): # type: (bool) -> None """ Set the link's dynamic enabled state. If the link is `dynamic` it will be rendered in red/green color respectively depending on the state of the dynamic enabled state. """ if self.__dynamicEnabled != enabled: self.__dynamicEnabled = enabled if self.__dynamic: self.__updatePen() def isDynamicEnabled(self): # type: () -> bool """ Is the link dynamic enabled. """ return self.__dynamicEnabled def setDynamic(self, dynamic): # type: (bool) -> None """ Mark the link as dynamic (i.e. it responds to :func:`setDynamicEnabled`). """ if self.__dynamic != dynamic: self.__dynamic = dynamic self.__updatePen() def isDynamic(self): # type: () -> bool """ Is the link dynamic. """ return self.__dynamic def setRuntimeState(self, state): # type: (_State) -> None """ Style the link appropriate to the LinkItem.State Parameters ---------- state : LinkItem.State """ if self.__state != state: self.__state = state self.__updateAnchors() self.__updatePen() def runtimeState(self): # type: () -> _State return self.__state def __updatePen(self): # type: () -> None self.prepareGeometryChange() self.__boundingRect = None if self.__dynamic: if self.__dynamicEnabled: color = QColor(0, 150, 0, 150) else: color = QColor(150, 0, 0, 150) normal = QPen(QBrush(color), 2.0) hover = QPen(QBrush(color.darker(120)), 2.0) else: normal = QPen(QBrush(QColor("#9CACB4")), 2.0) hover = QPen(QBrush(QColor("#959595")), 2.0) if self.__state & LinkItem.Empty: pen_style = Qt.DashLine else: pen_style = Qt.SolidLine normal.setStyle(pen_style) hover.setStyle(pen_style) if self.hover or self.isSelected(): pen = hover else: pen = normal self.curveItem.setPen(pen) def __updatePalette(self): # type: () -> None self.linkTextItem.setDefaultTextColor(self.palette().color( QPalette.Text)) def __updateFont(self): # type: () -> None font = self.font() # linkTextItem will be rotated. Hinting causes bad positioning under # rotation so we prefer to disable it. This is only a hint, on windows # (DirectWrite engine) vertical hinting is still performed. font.setHintingPreference(QFont.PreferNoHinting) self.linkTextItem.setFont(font) def __updateAnchors(self): state = QStyle.State(0) if self.hover: state |= QStyle.State_MouseOver if self.isSelected() or self.__isSelectedImplicit(): state |= QStyle.State_Selected if self.sinkAnchor is not None: self.sinkAnchor.indicator.setStyleState(state) self.sinkAnchor.indicator.setLinkState(self.__state) if self.sourceAnchor is not None: self.sourceAnchor.indicator.setStyleState(state) self.sourceAnchor.indicator.setLinkState(self.__state) def __updateSelectedState(self): selected = self.isSelected() or self.__isSelectedImplicit() self.linkTextItem.setSelectionState(selected) self.__updatePen() self.__updateAnchors() self.__updateChannelNameVisibility() self.curveItem.setSelectionState(selected) def __isSelectedImplicit(self): source, sink = self.sourceItem, self.sinkItem return (source is not None and source.isSelected() and sink is not None and sink.isSelected()) def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: Any) -> Any: if change == QGraphicsItem.ItemSelectedHasChanged: self.__updateSelectedState() self.selectedChanged.emit(value) return super().itemChange(change, value)