"""
=========
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)