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.__selected = False 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 if self.__hover != state: self.prepareGeometryChange() self.__hover = state self.__update() def setSelectionState(self, state): # type: (bool) -> None if self.__selected != state: self.prepareGeometryChange() self.__selected = 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 or self.__selected 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.stop() 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 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)