Source code for orangecanvas.canvas.items.linkitem

"""
=========
Link Item
=========

"""
import math
from xml.sax.saxutils import escape

import typing
from typing import Optional, Any

from AnyQt.QtWidgets import (
    QGraphicsItem, QGraphicsPathItem, QGraphicsWidget,
    QGraphicsDropShadowEffect, QGraphicsSceneHoverEvent, QStyle,
    QGraphicsSceneMouseEvent
)
from AnyQt.QtGui import (
    QPen, QBrush, QColor, QPainterPath, QTransform, QPalette, QFont,
)
from AnyQt.QtCore import Qt, QPointF, QRectF, QLineF, QEvent, QPropertyAnimation, Signal, QTimer

from .nodeitem import AnchorPoint, SHADOW_COLOR
from .graphicstextitem import GraphicsTextItem
from .utils import stroke_path, qpainterpath_sub_path
from ...registry import InputSignal, OutputSignal

from ...scheme import SchemeLink

if typing.TYPE_CHECKING:
    from . import NodeItem, AnchorPoint


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)


def path_link_disabled(basepath):
    # type: (QPainterPath) -> QPainterPath
    """
    Return a QPainterPath 'styled' to indicate a 'disabled' link.

    A disabled link is displayed with a single disconnection symbol in the
    middle (--||--)

    Parameters
    ----------
    basepath : QPainterPath
        The base path (a simple curve spine).

    Returns
    -------
    path : QPainterPath
        A 'styled' link path
    """
    segmentlen = basepath.length()
    px = 5

    if segmentlen < 10:
        return QPainterPath(basepath)

    t = (px / 2) / segmentlen
    p1 = qpainterpath_sub_path(basepath, 0.0, 0.50 - t)
    p2 = qpainterpath_sub_path(basepath, 0.50 + t, 1.0)

    angle = -basepath.angleAtPercent(0.5) + 90
    angler = math.radians(angle)
    normal = QPointF(math.cos(angler), math.sin(angler))

    end1 = p1.currentPosition()
    start2 = QPointF(p2.elementAt(0).x, p2.elementAt(0).y)
    p1.moveTo(start2.x(), start2.y())
    p1.addPath(p2)

    def QPainterPath_addLine(path, line):
        # type: (QPainterPath, QLineF) -> None
        path.moveTo(line.p1())
        path.lineTo(line.p2())

    QPainterPath_addLine(p1, QLineF(end1 - normal * 3, end1 + normal * 3))
    QPainterPath_addLine(p1, QLineF(start2 - normal * 3, start2 + normal * 3))
    return p1


_State = SchemeLink.State


[docs]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()
[docs] 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()
[docs] 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()
[docs] def setChannelNamesVisible(self, visible): # type: (bool) -> None """ Set the visibility of the channel name text. """ if self.__channelNamesVisible != visible: self.__channelNamesVisible = visible self.__initChannelNameOpacity()
[docs] 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()
[docs] def sourceName(self): # type: () -> str """ Return the source name. """ return self.__sourceName
[docs] 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()
[docs] def sinkName(self): # type: () -> str """ Return the sink name. """ return self.__sinkName
[docs] 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)
[docs] def mouseDoubleClickEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> None super().mouseDoubleClickEvent(event) QTimer.singleShot(0, self.activated.emit)
[docs] 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)
[docs] 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()
[docs] def changeEvent(self, event): # type: (QEvent) -> None if event.type() == QEvent.PaletteChange: self.__updatePalette() elif event.type() == QEvent.FontChange: self.__updateFont() super().changeEvent(event)
[docs] 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)
[docs] def boundingRect(self): # type: () -> QRectF if self.__boundingRect is None: self.__boundingRect = self.childrenBoundingRect() return self.__boundingRect
[docs] def shape(self): # type: () -> QPainterPath return self.curveItem.shape()
[docs] 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)
[docs] def isEnabled(self): # type: () -> bool return self.curveItem.isLinkEnabled()
[docs] 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()
[docs] def isDynamicEnabled(self): # type: () -> bool """ Is the link dynamic enabled. """ return self.__dynamicEnabled
[docs] 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()
[docs] def isDynamic(self): # type: () -> bool """ Is the link dynamic. """ return self.__dynamic
[docs] 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())
[docs] def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: Any) -> Any: if change == QGraphicsItem.ItemSelectedHasChanged: self.__updateSelectedState() self.selectedChanged.emit(value) return super().itemChange(change, value)