1"""
2=========
3Node Item
4=========
5
6"""
7import math
8import typing
9import string
10
11from operator import attrgetter
12from itertools import groupby
13from xml.sax.saxutils import escape
14
15from typing import Dict, Any, Optional, List, Iterable, Tuple, Union
16
17from AnyQt.QtWidgets import (
18    QGraphicsItem, QGraphicsObject, QGraphicsWidget,
19    QGraphicsDropShadowEffect, QStyle, QApplication, QGraphicsSceneMouseEvent,
20    QGraphicsSceneContextMenuEvent, QStyleOptionGraphicsItem, QWidget,
21    QGraphicsEllipseItem
22)
23from AnyQt.QtGui import (
24    QPen, QBrush, QColor, QPalette, QIcon, QPainter, QPainterPath,
25    QPainterPathStroker, QConicalGradient,
26    QTransform)
27from AnyQt.QtCore import (
28    Qt, QEvent, QPointF, QRectF, QRect, QSize, QTime, QTimer,
29    QPropertyAnimation, QEasingCurve, QObject, QVariantAnimation,
30    QParallelAnimationGroup, Slot)
31from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property
32from PyQt5.QtCore import pyqtProperty
33
34from .graphicspathobject import GraphicsPathObject
35from .graphicstextitem import GraphicsTextItem, GraphicsTextEdit
36from .utils import saturated, radial_gradient
37from ...gui.utils import disconnected
38
39from ...scheme.node import UserMessage
40from ...registry import NAMED_COLORS, WidgetDescription, CategoryDescription, \
41    InputSignal, OutputSignal
42from ...resources import icon_loader
43from .utils import uniform_linear_layout_trunc
44from ...utils import set_flag
45from ...utils.mathutils import interp1d
46
47if typing.TYPE_CHECKING:
48    from ...registry import WidgetDescription
49    # from . import LinkItem
50
51
52def create_palette(light_color, color):
53    # type: (QColor, QColor) -> QPalette
54    """
55    Return a new :class:`QPalette` from for the :class:`NodeBodyItem`.
56    """
57    palette = QPalette()
58
59    palette.setColor(QPalette.Inactive, QPalette.Light,
60                     saturated(light_color, 50))
61    palette.setColor(QPalette.Inactive, QPalette.Midlight,
62                     saturated(light_color, 90))
63    palette.setColor(QPalette.Inactive, QPalette.Button,
64                     light_color)
65
66    palette.setColor(QPalette.Active, QPalette.Light,
67                     saturated(color, 50))
68    palette.setColor(QPalette.Active, QPalette.Midlight,
69                     saturated(color, 90))
70    palette.setColor(QPalette.Active, QPalette.Button,
71                     color)
72    palette.setColor(QPalette.ButtonText, QColor("#515151"))
73    return palette
74
75
76def default_palette():
77    # type: () -> QPalette
78    """
79    Create and return a default palette for a node.
80    """
81    return create_palette(QColor(NAMED_COLORS["light-yellow"]),
82                          QColor(NAMED_COLORS["yellow"]))
83
84
85def animation_restart(animation):
86    # type: (QPropertyAnimation) -> None
87    if animation.state() == QPropertyAnimation.Running:
88        animation.pause()
89    animation.start()
90
91
92SHADOW_COLOR = "#9CACB4"
93SELECTED_SHADOW_COLOR = "#609ED7"
94
95
96class NodeBodyItem(GraphicsPathObject):
97    """
98    The central part (body) of the `NodeItem`.
99    """
100    def __init__(self, parent=None):
101        # type: (NodeItem) -> None
102        super().__init__(parent)
103        assert isinstance(parent, NodeItem)
104
105        self.__processingState = 0
106        self.__progress = -1.
107        self.__spinnerValue = 0
108        self.__animationEnabled = False
109        self.__isSelected = False
110        self.__hover = False
111        self.__shapeRect = QRectF(-10, -10, 20, 20)
112        self.palette = QPalette()
113        self.setAcceptHoverEvents(True)
114
115        self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, True)
116        self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True)
117
118        self.setPen(QPen(Qt.NoPen))
119
120        self.setPalette(default_palette())
121
122        self.shadow = QGraphicsDropShadowEffect(
123            blurRadius=0,
124            color=QColor(SHADOW_COLOR),
125            offset=QPointF(0, 0),
126        )
127        self.shadow.setEnabled(False)
128
129        # An item with the same shape as this object, stacked behind this
130        # item as a source for QGraphicsDropShadowEffect. Cannot attach
131        # the effect to this item directly as QGraphicsEffect makes the item
132        # non devicePixelRatio aware.
133        shadowitem = GraphicsPathObject(self, objectName="shadow-shape-item")
134        shadowitem.setPen(Qt.NoPen)
135        shadowitem.setBrush(QBrush(QColor(SHADOW_COLOR).lighter()))
136        shadowitem.setGraphicsEffect(self.shadow)
137        shadowitem.setFlag(QGraphicsItem.ItemStacksBehindParent)
138        self.__shadow = shadowitem
139        self.__blurAnimation = QPropertyAnimation(
140            self.shadow, b"blurRadius", self, duration=100
141        )
142        self.__blurAnimation.finished.connect(self.__on_finished)
143
144        self.__pingAnimation = QPropertyAnimation(
145            self, b"scale", self, duration=250
146        )
147        self.__pingAnimation.setKeyValues([(0.0, 1.0), (0.5, 1.1), (1.0, 1.0)])
148
149        self.__spinnerAnimation = QVariantAnimation(
150            self, startValue=0, endValue=360, duration=2000, loopCount=-1,
151        )
152        self.__spinnerAnimation.valueChanged.connect(self.update)
153        self.__spinnerStartTimer = QTimer(
154            self, interval=3000, singleShot=True,
155            timeout=self.__progressTimeout
156        )
157
158    # TODO: The body item should allow the setting of arbitrary painter
159    # paths (for instance rounded rect, ...)
160    def setShapeRect(self, rect):
161        # type: (QRectF) -> None
162        """
163        Set the item's shape `rect`. The item should be confined within
164        this rect.
165        """
166        path = QPainterPath()
167        path.addEllipse(rect)
168        self.setPath(path)
169        self.__shadow.setPath(path)
170        self.__shapeRect = rect
171
172    def setPalette(self, palette):
173        # type: (QPalette) -> None
174        """
175        Set the body color palette (:class:`QPalette`).
176        """
177        self.palette = QPalette(palette)
178        self.__updateBrush()
179
180    def setAnimationEnabled(self, enabled):
181        # type: (bool) -> None
182        """
183        Set the node animation enabled.
184        """
185        if self.__animationEnabled != enabled:
186            self.__animationEnabled = enabled
187
188    def setProcessingState(self, state):
189        # type: (int) -> None
190        """
191        Set the processing state of the node.
192        """
193        if self.__processingState != state:
194            self.__processingState = state
195            self.stopSpinner()
196            if not state and self.__animationEnabled:
197                self.ping()
198            if state:
199                self.__spinnerStartTimer.start()
200            else:
201                self.__spinnerStartTimer.stop()
202
203    def setProgress(self, progress):
204        # type: (float) -> None
205        """
206        Set the progress indicator state of the node. `progress` should
207        be a number between 0 and 100.
208        """
209        if self.__progress != progress:
210            self.__progress = progress
211            if self.__progress >= 0:
212                self.stopSpinner()
213            self.update()
214            self.__spinnerStartTimer.start()
215
216    def ping(self):
217        # type: () -> None
218        """
219        Trigger a 'ping' animation.
220        """
221        animation_restart(self.__pingAnimation)
222
223    def startSpinner(self):
224        self.__spinnerAnimation.start()
225        self.__spinnerStartTimer.stop()
226        self.update()
227
228    def stopSpinner(self):
229        self.__spinnerAnimation.stop()
230        self.__spinnerStartTimer.stop()
231        self.update()
232
233    def __progressTimeout(self):
234        if self.__processingState:
235            self.startSpinner()
236
237    def hoverEnterEvent(self, event):
238        self.__hover = True
239        self.__updateShadowState()
240        return super().hoverEnterEvent(event)
241
242    def hoverLeaveEvent(self, event):
243        self.__hover = False
244        self.__updateShadowState()
245        return super().hoverLeaveEvent(event)
246
247    def paint(self, painter, option, widget=None):
248        # type: (QPainter, QStyleOptionGraphicsItem, Optional[QWidget]) -> None
249        """
250        Paint the shape and a progress meter.
251        """
252        # Let the default implementation draw the shape
253        if option.state & QStyle.State_Selected:
254            # Prevent the default bounding rect selection indicator.
255            option.state = QStyle.State(option.state ^ QStyle.State_Selected)
256        super().paint(painter, option, widget)
257        if self.__progress >= 0 or self.__processingState \
258                or self.__spinnerAnimation.state() == QVariantAnimation.Running:
259            # Draw the progress meter over the shape.
260            # Set the clip to shape so the meter does not overflow the shape.
261            rect = self.__shapeRect
262            painter.save()
263            painter.setClipPath(self.shape(), Qt.ReplaceClip)
264            color = self.palette.color(QPalette.ButtonText)
265            pen = QPen(color, 5)
266            painter.setPen(pen)
267            spinner = self.__spinnerAnimation
268            indeterminate = spinner.state() != QVariantAnimation.Stopped
269            if indeterminate:
270                draw_spinner(painter, rect, 5, color,
271                             self.__spinnerAnimation.currentValue())
272            else:
273                span = max(1, int(360 * self.__progress / 100))
274                draw_progress(painter, rect, 5, color, span)
275            painter.restore()
276
277    def __updateShadowState(self):
278        # type: () -> None
279        if self.__isSelected or self.__hover:
280            enabled = True
281            radius = 17
282        else:
283            enabled = False
284            radius = 0
285
286        if enabled and not self.shadow.isEnabled():
287            self.shadow.setEnabled(enabled)
288
289        if self.__isSelected:
290            color = QColor(SELECTED_SHADOW_COLOR)
291        else:
292            color = QColor(SHADOW_COLOR)
293
294        self.shadow.setColor(color)
295
296        if self.__animationEnabled:
297            if self.__blurAnimation.state() == QPropertyAnimation.Running:
298                self.__blurAnimation.stop()
299
300            self.__blurAnimation.setStartValue(self.shadow.blurRadius())
301            self.__blurAnimation.setEndValue(radius)
302            self.__blurAnimation.start()
303        else:
304            self.shadow.setBlurRadius(radius)
305
306    def __updateBrush(self):
307        # type: () -> None
308        palette = self.palette
309        if self.__isSelected:
310            cg = QPalette.Active
311        else:
312            cg = QPalette.Inactive
313
314        palette.setCurrentColorGroup(cg)
315        c1 = palette.color(QPalette.Light)
316        c2 = palette.color(QPalette.Button)
317        grad = radial_gradient(c2, c1)
318        self.setBrush(QBrush(grad))
319
320    # TODO: The selected state should be set using the
321    # QStyle flags (State_Selected. State_HasFocus)
322
323    def setSelected(self, selected):
324        # type: (bool) -> None
325        """
326        Set the `selected` state.
327
328        .. note:: The item does not have `QGraphicsItem.ItemIsSelectable` flag.
329                  This property is instead controlled by the parent NodeItem.
330
331        """
332        self.__isSelected = selected
333        self.__updateShadowState()
334        self.__updateBrush()
335
336    def __on_finished(self):
337        # type: () -> None
338        if self.shadow.blurRadius() == 0:
339            self.shadow.setEnabled(False)
340
341
342class LinkAnchorIndicator(QGraphicsEllipseItem):
343    """
344    A visual indicator of the link anchor point at both ends
345    of the :class:`LinkItem`.
346
347    """
348    def __init__(self, parent=None):
349        # type: (Optional[QGraphicsItem]) -> None
350        self.__styleState = QStyle.State(0)
351        self.__linkState = LinkItem.NoState
352        super().__init__(parent)
353        self.setAcceptedMouseButtons(Qt.NoButton)
354        self.setRect(-3.5, -3.5, 7., 7.)
355        self.setPen(QPen(Qt.NoPen))
356        self.setBrush(QBrush(QColor("#9CACB4")))
357        self.hoverBrush = QBrush(QColor("#959595"))
358
359        self.__hover = False
360
361    def setHoverState(self, state):
362        # type: (bool) -> None
363        """
364        The hover state is set by the LinkItem.
365        """
366        state = set_flag(self.__styleState, QStyle.State_MouseOver, state)
367        self.setStyleState(state)
368
369    def setStyleState(self, state: QStyle.State):
370        if self.__styleState != state:
371            self.__styleState = state
372            self.update()
373
374    def setLinkState(self, state: 'LinkItem.State'):
375        if self.__linkState != state:
376            self.__linkState = state
377            self.update()
378
379    def paint(self, painter, option, widget=None):
380        # type: (QPainter, QStyleOptionGraphicsItem, Optional[QWidget]) -> None
381        hover = self.__styleState & (QStyle.State_Selected | QStyle.State_MouseOver)
382        brush = self.hoverBrush if hover else self.brush()
383        if self.__linkState & (LinkItem.Pending | LinkItem.Invalidated):
384            brush = QBrush(Qt.red)
385
386        painter.setBrush(brush)
387        painter.setPen(self.pen())
388        painter.drawEllipse(self.rect())
389
390
391def draw_spinner(painter, rect, penwidth, color, angle):
392    # type: (QPainter, QRectF, int, QColor, int) -> None
393    gradient = QConicalGradient()
394    color2 = QColor(color)
395    color2.setAlpha(0)
396
397    stops = [
398        (0.0, color),
399        (1.0, color2),
400    ]
401    gradient.setStops(stops)
402    gradient.setCoordinateMode(QConicalGradient.ObjectBoundingMode)
403    gradient.setCenter(0.5, 0.5)
404    gradient.setAngle(-angle)
405    pen = QPen()
406    pen.setCapStyle(Qt.RoundCap)
407    pen.setWidthF(penwidth)
408    pen.setBrush(gradient)
409    painter.setPen(pen)
410    painter.drawEllipse(rect)
411
412
413def draw_progress(painter, rect, penwidth, color, angle):
414    # type: (QPainter, QRectF, int, QColor, int) -> None
415    painter.setPen(QPen(color, penwidth))
416    painter.drawArc(rect, 90 * 16, -angle * 16)
417
418
419class AnchorPoint(QGraphicsObject):
420    """
421    A anchor indicator on the :class:`NodeAnchorItem`.
422    """
423
424    #: Signal emitted when the item's scene position changes.
425    scenePositionChanged = Signal(QPointF)
426
427    #: Signal emitted when the item's `anchorDirection` changes.
428    anchorDirectionChanged = Signal(QPointF)
429
430    #: Signal emitted when anchor's Input/Output channel changes.
431    signalChanged = Signal(QGraphicsObject)
432
433    def __init__(
434            self,
435            parent: Optional[QGraphicsItem] = None,
436            signal: Union[InputSignal, OutputSignal, None] = None,
437            **kwargs
438    ) -> None:
439        super().__init__(parent, **kwargs)
440        self.setFlag(QGraphicsItem.ItemIsFocusable)
441        self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, True)
442        self.setFlag(QGraphicsItem.ItemHasNoContents, True)
443        self.indicator = LinkAnchorIndicator(self)
444
445        self.signal = signal
446        self.__direction = QPointF()
447
448        self.anim = QPropertyAnimation(self, b'pos', self)
449        self.anim.setDuration(50)
450
451    def setSignal(self, signal):
452        if self.signal != signal:
453            self.signal = signal
454            self.signalChanged.emit(self)
455
456    def anchorScenePos(self):
457        # type: () -> QPointF
458        """
459        Return anchor position in scene coordinates.
460        """
461        return self.mapToScene(QPointF(0, 0))
462
463    def setAnchorDirection(self, direction):
464        # type: (QPointF) -> None
465        """
466        Set the preferred direction (QPointF) in item coordinates.
467        """
468        if self.__direction != direction:
469            self.__direction = QPointF(direction)
470            self.anchorDirectionChanged.emit(direction)
471
472    def anchorDirection(self):
473        # type: () -> QPointF
474        """
475        Return the preferred anchor direction.
476        """
477        return QPointF(self.__direction)
478
479    def itemChange(self, change, value):
480        # type: (QGraphicsItem.GraphicsItemChange, Any) -> Any
481        if change == QGraphicsItem.ItemScenePositionHasChanged:
482            self.scenePositionChanged.emit(value)
483        return super().itemChange(change, value)
484
485    def boundingRect(self,):
486        # type: () -> QRectF
487        return QRectF()
488
489    def setHoverState(self, enabled):
490        self.indicator.setHoverState(enabled)
491
492    def setLinkState(self, state: 'LinkItem.State'):
493        self.indicator.setLinkState(state)
494
495
496def drawDashPattern(dashNum, spaceLen=2, lineLen=16):
497    dashLen = (lineLen - spaceLen * (dashNum - 1)) / dashNum
498    line = []
499    for _ in range(dashNum - 1):
500        line += [dashLen, spaceLen]
501    line += [dashLen]
502    return line
503
504
505def matchDashPattern(l1, l2, spaceLen=2):
506    if not l1 or not l2 or len(l1) == len(l2):
507        return l1, l2
508
509    if len(l2) < len(l1):
510        l1, l2 = l2, l1
511        reverse = True
512    else:
513        reverse = False
514
515    l1d = len(l1) // 2 + 1
516    l2d = len(l2) // 2 + 1
517
518    if l1d == 1:  # base case
519        dLen = l1[0]
520        l1 = drawDashPattern(l2d, spaceLen=0, lineLen=dLen)
521        return (l2, l1) if reverse else (l1, l2)
522
523    d = math.gcd(l1d, l2d)
524    if d > 1:  # split
525        l1step = (l1d // d) * 2
526        l2step = (l2d // d) * 2
527        l1range = l1step - 1
528        l2range = l2step - 1
529        l1splits, l2splits = [], []
530        for l1i, l2i in zip(range(0, len(l1), l1step), range(0, len(l2), l2step)):
531            l1s = l1[l1i:(l1i+l1range)]
532            l2s = l2[l2i:(l2i+l2range)]
533            l1splits += [l1s]
534            l2splits += [l2s]
535
536    elif l1d % 2 == 0 and l2d % 2 != 0:  # split middle 2 lines into 3
537        l11 = l1[:l1d-2]
538        l1l = l1[l1d]
539        l12 = l1[l1d+1:]
540
541        l21 = l2[:l2d-3]
542        l2l = l2[l2d-1]
543        l22 = l2[l2d+2:]
544
545        new_l11, new_l21 = matchDashPattern(l11, l21)
546        new_l12, new_l22 = matchDashPattern(l12, l22)
547        for new_l in (new_l11, new_l21):
548            if new_l:
549                new_l += [spaceLen]
550        for new_l in (new_l12, new_l22):
551            if new_l:
552                new_l.insert(0, spaceLen)
553
554        l1 = new_l11 + [l1l*2/3, 0, l1l/3, spaceLen, l1l/3, 0, l1l*2/3] + new_l12
555        l2 = new_l21 + [l2l, spaceLen, l2l/2, 0, l2l/2, spaceLen, l2l] + new_l22
556        return (l2, l1) if reverse else (l1, l2)
557
558    elif l1d % 2 != 0 and l2d % 2 == 0:  # split line
559        l11 = l1[:l1d - 2]
560        mid = l1[l1d-1]
561        l1m = [mid/2, 0, mid/2]
562        l12 = l1[l1d+1:]
563
564        l21 = l2[:l2d-3]
565        l2m = l2[l2d-2:l2d+1]
566        l22 = l2[l2d+2:]
567
568        l1splits = [l11, l1m, l12]
569        l2splits = [l21, l2m, l22]
570    else:  # if l1d % 2 != 0 and l2d % 2 != 0
571        l11 = l1[:l1d - 1]
572        l1m = l1[l1d]
573        l12 = l1[l1d + 2:]
574
575        l21 = l2[:l2d - 1]
576        l2m = l2[l2d]
577        l22 = l2[l2d + 2:]
578
579        l1splits = [l11, l1m, l12]
580        l2splits = [l21, l2m, l22]
581
582    l1 = []
583    l2 = []
584    for l1s, l2s in zip(l1splits, l2splits):
585        new_l1, new_l2 = matchDashPattern(l1s, l2s)
586        l1 += new_l1 + [spaceLen]
587        l2 += new_l2 + [spaceLen]
588    # drop trailing space
589    l1 = l1[:-1]
590    l2 = l2[:-1]
591    return (l2, l1) if reverse else (l1, l2)
592
593
594ANCHOR_TEXT_MARGIN = 4
595
596
597class NodeAnchorItem(GraphicsPathObject):
598    """
599    The left/right widget input/output anchors.
600    """
601    def __init__(self, parent, **kwargs):
602        # type: (Optional[QGraphicsItem], Any) -> None
603        super().__init__(parent, **kwargs)
604        self.__parentNodeItem = None  # type: Optional[NodeItem]
605        self.setAcceptHoverEvents(True)
606        self.setPen(QPen(Qt.NoPen))
607        self.normalBrush = QBrush(QColor("#CDD5D9"))
608        self.normalHoverBrush = QBrush(QColor("#9CACB4"))
609        self.connectedBrush = self.normalHoverBrush
610        self.connectedHoverBrush = QBrush(QColor("#959595"))
611        self.setBrush(self.normalBrush)
612
613        self.__animationEnabled = False
614        self.__hover = False
615        self.__anchorOpen = False
616        self.__compatibleSignals = None
617        self.__keepSignalsOpen = []
618
619        # Does this item have any anchored links.
620        self.anchored = False
621
622        if isinstance(parent, NodeItem):
623            self.__parentNodeItem = parent
624        else:
625            self.__parentNodeItem = None
626
627        self.__anchorPath = QPainterPath()
628        self.__points = []  # type: List[AnchorPoint]
629        self.__uniformPointPositions = []  # type: List[float]
630        self.__channelPointPositions = []  # type: List[float]
631        self.__incompatible = False  # type: bool
632        self.__signals = []  # type: List[Union[InputSignal, OutputSignal]]
633        self.__signalLabels = []  # type: List[GraphicsTextItem]
634        self.__signalLabelAnims = []  # type: List[QPropertyAnimation]
635
636        self.__fullStroke = QPainterPath()
637        self.__dottedStroke = QPainterPath()
638        self.__channelStroke = QPainterPath()
639        self.__shape = None  # type: Optional[QPainterPath]
640
641        self.shadow = QGraphicsDropShadowEffect(
642            blurRadius=0,
643            color=QColor(SHADOW_COLOR),
644            offset=QPointF(0, 0),
645        )
646        # self.setGraphicsEffect(self.shadow)
647        self.shadow.setEnabled(False)
648
649        shadowitem = GraphicsPathObject(self, objectName="shadow-shape-item")
650        shadowitem.setPen(Qt.NoPen)
651        shadowitem.setBrush(QBrush(QColor(SHADOW_COLOR)))
652        shadowitem.setGraphicsEffect(self.shadow)
653        shadowitem.setFlag(QGraphicsItem.ItemStacksBehindParent)
654        self.__shadow = shadowitem
655        self.__blurAnimation = QPropertyAnimation(self.shadow, b"blurRadius",
656                                                  self)
657        self.__blurAnimation.setDuration(50)
658        self.__blurAnimation.finished.connect(self.__on_finished)
659
660        stroke_path = QPainterPathStroker()
661        stroke_path.setCapStyle(Qt.RoundCap)
662        stroke_path.setWidth(3)
663        self.__pathStroker = stroke_path
664        self.__interpDash = None
665        self.__dashInterpFactor = 0
666        self.__anchorPathAnim = QPropertyAnimation(self,
667                                                   b"anchorDashInterpFactor",
668                                                   self)
669        self.__anchorPathAnim.setDuration(50)
670
671        self.animGroup = QParallelAnimationGroup()
672        self.animGroup.addAnimation(self.__anchorPathAnim)
673
674    def setSignals(self, signals):
675        self.__signals = signals
676        self.setAnchorPath(self.__anchorPath)  # (re)instantiate anchor paths
677
678        # TODO this is ugly
679        alignLeft = isinstance(self, SourceAnchorItem)
680
681        for s in signals:
682            lbl = GraphicsTextItem(self)
683            lbl.setAcceptedMouseButtons(Qt.NoButton)
684            lbl.setAcceptHoverEvents(False)
685
686            text = s.name
687            lbl.setHtml('<div align="' + ('left' if alignLeft else 'right') +
688                        '" style="font-size: small; background-color: palette(base);" >{0}</div>'
689                        .format(text))
690
691            cperc = self.__getChannelPercent(s)
692            sigPos = self.__anchorPath.pointAtPercent(cperc)
693            lblrect = lbl.boundingRect()
694
695            transform = QTransform()
696            transform.translate(sigPos.x(), sigPos.y())
697            transform.translate(0, -lblrect.height() / 2)
698            if not alignLeft:
699                transform.translate(-lblrect.width() - ANCHOR_TEXT_MARGIN, 0)
700            else:
701                transform.translate(ANCHOR_TEXT_MARGIN, 0)
702
703            lbl.setTransform(transform)
704            lbl.setOpacity(0)
705            self.__signalLabels.append(lbl)
706
707            lblAnim = QPropertyAnimation(lbl, b'opacity', self)
708            lblAnim.setDuration(50)
709            self.animGroup.addAnimation(lblAnim)
710            self.__signalLabelAnims.append(lblAnim)
711
712    def setIncompatible(self, enabled):
713        if self.__incompatible != enabled:
714            self.__incompatible = enabled
715            self.__updatePositions()
716
717    def setKeepAnchorOpen(self, signal):
718        if signal is None:
719            self.__keepSignalsOpen = []
720        elif not isinstance(signal, list):
721            self.__keepSignalsOpen = [signal]
722        else:
723            self.__keepSignalsOpen = signal
724        self.__updateLabels(self.__keepSignalsOpen)
725
726    def parentNodeItem(self):
727        # type: () -> Optional['NodeItem']
728        """
729        Return a parent :class:`NodeItem` or ``None`` if this anchor's
730        parent is not a :class:`NodeItem` instance.
731        """
732        return self.__parentNodeItem
733
734    def setAnchorPath(self, path):
735        # type: (QPainterPath) -> None
736        """
737        Set the anchor's curve path as a :class:`QPainterPath`.
738        """
739        self.__anchorPath = QPainterPath(path)
740        # Create a stroke of the path.
741        stroke_path = QPainterPathStroker()
742        stroke_path.setCapStyle(Qt.RoundCap)
743
744        # Shape is wider (bigger mouse hit area - should be settable)
745        stroke_path.setWidth(25)
746        self.prepareGeometryChange()
747        self.__shape = stroke_path.createStroke(path)
748        stroke_path.setWidth(3)
749
750        # Match up dash patterns for animations
751        dash6 = drawDashPattern(6)
752        channelAnchor = drawDashPattern(len(self.__signals) or 1)
753        fullAnchor = drawDashPattern(1)
754        dash6, channelAnchor = matchDashPattern(dash6, channelAnchor)
755        channelAnchor, fullAnchor = matchDashPattern(channelAnchor, fullAnchor)
756        self.__unanchoredDash = dash6
757        self.__channelDash = channelAnchor
758        self.__anchoredDash = fullAnchor
759
760        # The full stroke
761        stroke_path.setDashPattern(fullAnchor)
762        self.__fullStroke = stroke_path.createStroke(path)
763
764        # The dotted stroke (when not connected to anything)
765        stroke_path.setDashPattern(dash6)
766        self.__dottedStroke = stroke_path.createStroke(path)
767
768        # The channel stroke (when channels are open)
769        stroke_path.setDashPattern(channelAnchor)
770        self.__channelStroke = stroke_path.createStroke(path)
771
772        if self.anchored:
773            self.setPath(self.__fullStroke)
774            self.__pathStroker.setDashPattern(self.__anchoredDash)
775            self.__shadow.setPath(self.__fullStroke)
776            brush = self.connectedHoverBrush if self.__hover else self.connectedBrush
777            self.setBrush(brush)
778        else:
779            self.setPath(self.__dottedStroke)
780            self.__pathStroker.setDashPattern(self.__unanchoredDash)
781            self.__shadow.setPath(self.__dottedStroke)
782            brush = self.normalHoverBrush if self.__hover else self.normalBrush
783            self.setBrush(brush)
784
785    def anchorPath(self):
786        # type: () -> QPainterPath
787        """
788        Return the anchor path (:class:`QPainterPath`). This is a curve on
789        which the anchor points lie.
790        """
791        return QPainterPath(self.__anchorPath)
792
793    def anchorOpen(self):
794        return self.__anchorOpen
795
796    @pyqtProperty(float)
797    def anchorDashInterpFactor(self):
798        return self.__dashInterpFactor
799
800    @anchorDashInterpFactor.setter
801    def anchorDashInterpFactor(self, value):
802        self.__dashInterpFactor = value
803        stroke_path = self.__pathStroker
804        path = self.__anchorPath
805
806        pattern = self.__interpDash(value)
807        stroke_path.setDashPattern(pattern)
808        stroke = stroke_path.createStroke(path)
809        self.setPath(stroke)
810        self.__shadow.setPath(stroke)
811
812    def setAnchored(self, anchored):
813        # type: (bool) -> None
814        """
815        Set the items anchored state. When ``False`` the item draws it self
816        with a dotted stroke.
817        """
818        self.anchored = anchored
819        if anchored:
820            self.shadow.setEnabled(False)
821            self.setBrush(self.connectedBrush)
822        else:
823            brush = self.normalHoverBrush if self.__hover else self.normalBrush
824            self.setBrush(brush)
825        self.__updatePositions()
826
827    def setConnectionHint(self, hint=None):
828        """
829        Set the connection hint. This can be used to indicate if
830        a connection can be made or not.
831
832        """
833        raise NotImplementedError
834
835    def count(self):
836        # type: () -> int
837        """
838        Return the number of anchor points.
839        """
840        return len(self.__points)
841
842    def addAnchor(self, anchor):
843        # type: (AnchorPoint) -> int
844        """
845        Add a new :class:`AnchorPoint` to this item and return it's index.
846
847        The `position` specifies where along the `anchorPath` is the new
848        point inserted.
849
850        """
851        return self.insertAnchor(self.count(), anchor)
852
853    def __updateAnchorSignalPosition(self, anchor):
854        cperc = self.__getChannelPercent(anchor.signal)
855        i = self.__points.index(anchor)
856        self.__channelPointPositions[i] = cperc
857        self.__updatePositions()
858
859    def insertAnchor(self, index, anchor):
860        # type: (int, AnchorPoint) -> int
861        """
862        Insert a new :class:`AnchorPoint` at `index`.
863
864        See also
865        --------
866        NodeAnchorItem.addAnchor
867
868        """
869        if anchor in self.__points:
870            raise ValueError("%s already added." % anchor)
871
872        self.__points.insert(index, anchor)
873        self.__uniformPointPositions.insert(index, 0)
874        cperc = self.__getChannelPercent(anchor.signal)
875        self.__channelPointPositions.insert(index, cperc)
876        self.animGroup.addAnimation(anchor.anim)
877
878        anchor.setParentItem(self)
879        anchor.destroyed.connect(self.__onAnchorDestroyed)
880        anchor.signalChanged.connect(self.__updateAnchorSignalPosition)
881
882        positions = self.anchorPositions()
883        positions = uniform_linear_layout_trunc(positions)
884
885        if anchor.signal in self.__keepSignalsOpen or \
886                self.__anchorOpen and self.__hover:
887            perc = cperc
888        else:
889            perc = positions[index]
890        pos = self.__anchorPath.pointAtPercent(perc)
891        anchor.setPos(pos)
892
893        self.setAnchorPositions(positions)
894
895        self.setAnchored(bool(self.__points))
896
897        hover_for_color = self.__hover and len(self.__points) > 1  # a stylistic choice
898        anchor.setHoverState(hover_for_color)
899        return index
900
901    def removeAnchor(self, anchor):
902        # type: (AnchorPoint) -> None
903        """
904        Remove and delete the anchor point.
905        """
906        anchor = self.takeAnchor(anchor)
907        self.animGroup.removeAnimation(anchor.anim)
908
909        anchor.hide()
910        anchor.setParentItem(None)
911        anchor.deleteLater()
912
913        positions = self.anchorPositions()
914        positions = uniform_linear_layout_trunc(positions)
915        self.setAnchorPositions(positions)
916
917    def takeAnchor(self, anchor):
918        # type: (AnchorPoint) -> AnchorPoint
919        """
920        Remove the anchor but don't delete it.
921        """
922        index = self.__points.index(anchor)
923
924        del self.__points[index]
925        del self.__uniformPointPositions[index]
926        del self.__channelPointPositions[index]
927
928        anchor.destroyed.disconnect(self.__onAnchorDestroyed)
929
930        self.__updatePositions()
931
932        self.setAnchored(bool(self.__points))
933
934        return anchor
935
936    def __onAnchorDestroyed(self, anchor):
937        # type: (QObject) -> None
938        try:
939            index = self.__points.index(anchor)
940        except ValueError:
941            return
942
943        del self.__points[index]
944        del self.__uniformPointPositions[index]
945        del self.__channelPointPositions[index]
946
947    def anchorPoints(self):
948        # type: () -> List[AnchorPoint]
949        """
950        Return a list of anchor points.
951        """
952        return list(self.__points)
953
954    def anchorPoint(self, index):
955        # type: (int) -> AnchorPoint
956        """
957        Return the anchor point at `index`.
958        """
959        return self.__points[index]
960
961    def setAnchorPositions(self, positions):
962        # type: (Iterable[float]) -> None
963        """
964        Set the anchor positions in percentages (0..1) along the path curve.
965        """
966        if self.__uniformPointPositions != positions:
967            self.__uniformPointPositions = list(positions)
968            self.__updatePositions()
969
970    def anchorPositions(self):
971        # type: () -> List[float]
972        """
973        Return the positions of anchor points as a list of floats where
974        each float is between 0 and 1 and specifies where along the anchor
975        path does the point lie (0 is at start 1 is at the end).
976        """
977        return list(self.__uniformPointPositions)
978
979    def shape(self):
980        # type: () -> QPainterPath
981        if self.__shape is not None:
982            return QPainterPath(self.__shape)
983        else:
984            return super().shape()
985
986    def boundingRect(self):
987        if self.__shape is not None:
988            return self.__shape.controlPointRect()
989        else:
990            return GraphicsPathObject.boundingRect(self)
991
992    def setHovered(self, enabled):
993        self.__hover = enabled
994        if enabled:
995            brush = self.connectedHoverBrush if self.anchored else self.normalHoverBrush
996        else:
997            brush = self.connectedBrush if self.anchored else self.normalBrush
998        self.setBrush(brush)
999        self.__updateHoverState()
1000
1001    def hoverEnterEvent(self, event):
1002        self.setHovered(True)
1003        return super().hoverEnterEvent(event)
1004
1005    def hoverLeaveEvent(self, event):
1006        self.setHovered(False)
1007        return super().hoverLeaveEvent(event)
1008
1009    def setAnimationEnabled(self, enabled):
1010        # type: (bool) -> None
1011        """
1012        Set the anchor animation enabled.
1013        """
1014        if self.__animationEnabled != enabled:
1015            self.__animationEnabled = enabled
1016
1017    def signalAtPos(self, scenePos, signalsToFind=None):
1018        if signalsToFind is None:
1019            signalsToFind = self.__signals
1020        pos = self.mapFromScene(scenePos)
1021
1022        def signalLengthToPos(s):
1023            perc = self.__getChannelPercent(s)
1024            p = self.__anchorPath.pointAtPercent(perc)
1025            return (p - pos).manhattanLength()
1026
1027        return min(signalsToFind, key=signalLengthToPos)
1028
1029    def __updateHoverState(self):
1030        self.__updateShadowState()
1031        self.__updatePositions()
1032
1033        for indicator in self.anchorPoints():
1034            indicator.setHoverState(self.__hover)
1035
1036    def __getChannelPercent(self, signal):
1037        if signal is None:
1038            return 0.5
1039        signals = self.__signals
1040
1041        ci = signals.index(signal)
1042        gap_perc = 1 / 8
1043        seg_perc = (1 - (gap_perc * (len(signals) - 1))) / len(signals)
1044        return (ci * (gap_perc + seg_perc)) + seg_perc / 2
1045
1046    def __updateShadowState(self):
1047        # type: () -> None
1048        radius = 5 if self.__hover else 0
1049
1050        if radius != 0 and not self.shadow.isEnabled():
1051            self.shadow.setEnabled(True)
1052
1053        if self.__animationEnabled:
1054            if self.__blurAnimation.state() == QPropertyAnimation.Running:
1055                self.__blurAnimation.stop()
1056
1057            self.__blurAnimation.setStartValue(self.shadow.blurRadius())
1058            self.__blurAnimation.setEndValue(radius)
1059            self.__blurAnimation.start()
1060        else:
1061            self.shadow.setBlurRadius(radius)
1062
1063    def setAnchorOpen(self, anchorOpen):
1064        self.__anchorOpen = anchorOpen
1065        self.__updatePositions()
1066
1067    def setCompatibleSignals(self, compatibleSignals):
1068        self.__compatibleSignals = compatibleSignals
1069        self.__updatePositions()
1070
1071    def __updateLabels(self, showSignals):
1072        for signal, label in zip(self.__signals, self.__signalLabels):
1073            if signal not in showSignals:
1074                opacity = 0
1075            elif self.__compatibleSignals is not None \
1076                    and signal not in self.__compatibleSignals:
1077                opacity = 0.65
1078            else:
1079                opacity = 1
1080            label.setOpacity(opacity)
1081
1082    def __initializeAnimation(self, targetPoss, endDash, showSignals):
1083        anchorOpen = self.__anchorOpen
1084        # TODO if animation currently running, set start value/time accordingly
1085        for a, t in zip(self.__points, targetPoss):
1086            currPos = a.pos()
1087            a.anim.setStartValue(currPos)
1088            pos = self.__anchorPath.pointAtPercent(t)
1089            a.anim.setEndValue(pos)
1090
1091        for sig, lbl, lblAnim in zip(self.__signals, self.__signalLabels, self.__signalLabelAnims):
1092            lblAnim.setStartValue(lbl.opacity())
1093            lblAnim.setEndValue(1 if sig in showSignals else 0)
1094
1095        startDash = self.__pathStroker.dashPattern()
1096        self.__interpDash = interp1d(startDash, endDash)
1097        self.__anchorPathAnim.setStartValue(0)
1098        self.__anchorPathAnim.setEndValue(1)
1099
1100    def __updatePositions(self):
1101        # type: () -> None
1102        """Update anchor points positions.
1103        """
1104        if self.__keepSignalsOpen or self.__anchorOpen and self.__hover:
1105            dashPattern = self.__channelDash
1106            stroke = self.__channelStroke
1107            targetPoss = self.__channelPointPositions
1108            showSignals = self.__keepSignalsOpen or self.__signals
1109        elif self.anchored:
1110            dashPattern = self.__anchoredDash
1111            stroke = self.__fullStroke
1112            targetPoss = self.__uniformPointPositions
1113            showSignals = self.__signals if self.__incompatible else []
1114        else:
1115            dashPattern = self.__unanchoredDash
1116            stroke = self.__dottedStroke
1117            targetPoss = self.__uniformPointPositions
1118            showSignals = self.__signals if self.__incompatible else []
1119
1120        if self.animGroup.state() == QPropertyAnimation.Running:
1121            self.animGroup.stop()
1122        if self.__animationEnabled:
1123            self.__initializeAnimation(targetPoss, dashPattern, showSignals)
1124            self.animGroup.start()
1125        else:
1126            for point, t in zip(self.__points, targetPoss):
1127                pos = self.__anchorPath.pointAtPercent(t)
1128                point.setPos(pos)
1129            self.__updateLabels(showSignals)
1130            self.__pathStroker.setDashPattern(dashPattern)
1131            self.setPath(stroke)
1132            self.__shadow.setPath(stroke)
1133
1134    def __on_finished(self):
1135        # type: () -> None
1136        if self.shadow.blurRadius() == 0:
1137            self.shadow.setEnabled(False)
1138
1139
1140class SourceAnchorItem(NodeAnchorItem):
1141    """
1142    A source anchor item
1143    """
1144    pass
1145
1146
1147class SinkAnchorItem(NodeAnchorItem):
1148    """
1149    A sink anchor item.
1150    """
1151    pass
1152
1153
1154def standard_icon(standard_pixmap):
1155    # type: (QStyle.StandardPixmap) -> QIcon
1156    """
1157    Return return the application style's standard icon for a
1158    `QStyle.StandardPixmap`.
1159    """
1160    style = QApplication.instance().style()
1161    return style.standardIcon(standard_pixmap)
1162
1163
1164class GraphicsIconItem(QGraphicsWidget):
1165    """
1166    A graphics item displaying an :class:`QIcon`.
1167    """
1168    def __init__(self, parent=None, icon=QIcon(), iconSize=QSize(), **kwargs):
1169        # type: (Optional[QGraphicsItem], QIcon, QSize, Any) -> None
1170        super().__init__(parent, **kwargs)
1171        self.setFlag(QGraphicsItem.ItemUsesExtendedStyleOption, True)
1172
1173        if icon is None:
1174            icon = QIcon()
1175
1176        if iconSize is None or iconSize.isNull():
1177            style = QApplication.instance().style()
1178            size = style.pixelMetric(style.PM_LargeIconSize)
1179            iconSize = QSize(size, size)
1180
1181        self.__transformationMode = Qt.SmoothTransformation
1182
1183        self.__iconSize = QSize(iconSize)
1184        self.__icon = QIcon(icon)
1185
1186        self.anim = QPropertyAnimation(self, b"opacity")
1187        self.anim.setDuration(350)
1188        self.anim.setStartValue(1)
1189        self.anim.setKeyValueAt(0.5, 0)
1190        self.anim.setEndValue(1)
1191        self.anim.setEasingCurve(QEasingCurve.OutQuad)
1192        self.anim.setLoopCount(5)
1193
1194    def setIcon(self, icon):
1195        # type: (QIcon) -> None
1196        """
1197        Set the icon (:class:`QIcon`).
1198        """
1199        if self.__icon != icon:
1200            self.__icon = QIcon(icon)
1201            self.update()
1202
1203    def icon(self):
1204        # type: () -> QIcon
1205        """
1206        Return the icon (:class:`QIcon`).
1207        """
1208        return QIcon(self.__icon)
1209
1210    def setIconSize(self, size):
1211        # type: (QSize) -> None
1212        """
1213        Set the icon (and this item's) size (:class:`QSize`).
1214        """
1215        if self.__iconSize != size:
1216            self.prepareGeometryChange()
1217            self.__iconSize = QSize(size)
1218            self.update()
1219
1220    def iconSize(self):
1221        # type: () -> QSize
1222        """
1223        Return the icon size (:class:`QSize`).
1224        """
1225        return QSize(self.__iconSize)
1226
1227    def setTransformationMode(self, mode):
1228        # type: (Qt.TransformationMode) -> None
1229        """
1230        Set pixmap transformation mode. (`Qt.SmoothTransformation` or
1231        `Qt.FastTransformation`).
1232
1233        """
1234        if self.__transformationMode != mode:
1235            self.__transformationMode = mode
1236            self.update()
1237
1238    def transformationMode(self):
1239        # type: () -> Qt.TransformationMode
1240        """
1241        Return the pixmap transformation mode.
1242        """
1243        return self.__transformationMode
1244
1245    def boundingRect(self):
1246        # type: () -> QRectF
1247        return QRectF(0, 0, self.__iconSize.width(), self.__iconSize.height())
1248
1249    def paint(self, painter, option, widget=None):
1250        # type: (QPainter, QStyleOptionGraphicsItem, Optional[QWidget]) -> None
1251        if not self.__icon.isNull():
1252            if option.state & QStyle.State_Selected:
1253                mode = QIcon.Selected
1254            elif option.state & QStyle.State_Enabled:
1255                mode = QIcon.Normal
1256            elif option.state & QStyle.State_Active:
1257                mode = QIcon.Active
1258            else:
1259                mode = QIcon.Disabled
1260
1261            w, h = self.__iconSize.width(), self.__iconSize.height()
1262            target = QRect(0, 0, w, h)
1263            painter.setRenderHint(
1264                QPainter.SmoothPixmapTransform,
1265                self.__transformationMode == Qt.SmoothTransformation
1266            )
1267            self.__icon.paint(painter, target, Qt.AlignCenter, mode)
1268
1269
1270class NodeItem(QGraphicsWidget):
1271    """
1272    An widget node item in the canvas.
1273    """
1274
1275    #: Signal emitted when the scene position of the node has changed.
1276    positionChanged = Signal()
1277
1278    #: Signal emitted when the geometry of the channel anchors changes.
1279    anchorGeometryChanged = Signal()
1280
1281    #: Signal emitted when the item has been activated (by a mouse double
1282    #: click or a keyboard)
1283    activated = Signal()
1284
1285    #: The item is under the mouse.
1286    hovered = Signal()
1287
1288    #: Signal emitted the the item's selection state changes.
1289    selectedChanged = Signal(bool)
1290
1291    #: Span of the anchor in degrees
1292    ANCHOR_SPAN_ANGLE = 90
1293
1294    #: Z value of the item
1295    Z_VALUE = 100
1296
1297    def __init__(self, widget_description=None, parent=None, **kwargs):
1298        # type: (WidgetDescription, QGraphicsItem, Any) -> None
1299        self.__boundingRect = None  # type: Optional[QRectF]
1300        super().__init__(parent, **kwargs)
1301        self.setFocusPolicy(Qt.ClickFocus)
1302        self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True)
1303        self.setFlag(QGraphicsItem.ItemIsSelectable, True)
1304        self.setFlag(QGraphicsItem.ItemIsMovable, True)
1305        self.setFlag(QGraphicsItem.ItemIsFocusable, True)
1306
1307        self.mousePressTime = QTime()
1308        self.mousePressTime.start()
1309
1310        self.__title = ""
1311        self.__processingState = 0
1312        self.__progress = -1.
1313        self.__statusMessage = ""
1314        self.__renderedText = ""
1315
1316        self.__error = None    # type: Optional[str]
1317        self.__warning = None  # type: Optional[str]
1318        self.__info = None     # type: Optional[str]
1319        self.__messages = {}  # type: Dict[Any, UserMessage]
1320        self.__anchorLayout = None
1321        self.__animationEnabled = False
1322
1323        self.setZValue(self.Z_VALUE)
1324
1325        shape_rect = QRectF(-24, -24, 48, 48)
1326
1327        self.shapeItem = NodeBodyItem(self)
1328        self.shapeItem.setShapeRect(shape_rect)
1329        self.shapeItem.setAnimationEnabled(self.__animationEnabled)
1330
1331        # Rect for widget's 'ears'.
1332        anchor_rect = QRectF(-31, -31, 62, 62)
1333        self.inputAnchorItem = SinkAnchorItem(self)
1334        input_path = QPainterPath()
1335        start_angle = 180 - self.ANCHOR_SPAN_ANGLE / 2
1336        input_path.arcMoveTo(anchor_rect, start_angle)
1337        input_path.arcTo(anchor_rect, start_angle, self.ANCHOR_SPAN_ANGLE)
1338        self.inputAnchorItem.setAnchorPath(input_path)
1339        self.inputAnchorItem.setAnimationEnabled(self.__animationEnabled)
1340
1341        self.outputAnchorItem = SourceAnchorItem(self)
1342        output_path = QPainterPath()
1343        start_angle = self.ANCHOR_SPAN_ANGLE / 2
1344        output_path.arcMoveTo(anchor_rect, start_angle)
1345        output_path.arcTo(anchor_rect, start_angle, - self.ANCHOR_SPAN_ANGLE)
1346        self.outputAnchorItem.setAnchorPath(output_path)
1347        self.outputAnchorItem.setAnimationEnabled(self.__animationEnabled)
1348
1349        self.inputAnchorItem.hide()
1350        self.outputAnchorItem.hide()
1351
1352        # Title caption item
1353        self.captionTextItem = GraphicsTextEdit(
1354            self, editTriggers=GraphicsTextEdit.NoEditTriggers,
1355            returnKeyEndsEditing=True,
1356        )
1357        self.captionTextItem.setTabChangesFocus(True)
1358        self.captionTextItem.setPlainText("")
1359        self.captionTextItem.setPos(0, 33)
1360
1361        def iconItem(standard_pixmap):
1362            # type: (QStyle.StandardPixmap) -> GraphicsIconItem
1363            item = GraphicsIconItem(
1364                self,
1365                icon=standard_icon(standard_pixmap),
1366                iconSize=QSize(16, 16)
1367            )
1368            item.hide()
1369            return item
1370
1371        self.errorItem = iconItem(QStyle.SP_MessageBoxCritical)
1372        self.warningItem = iconItem(QStyle.SP_MessageBoxWarning)
1373        self.infoItem = iconItem(QStyle.SP_MessageBoxInformation)
1374
1375        self.prepareGeometryChange()
1376        self.__boundingRect = None
1377
1378        if widget_description is not None:
1379            self.setWidgetDescription(widget_description)
1380
1381    @classmethod
1382    def from_node(cls, node):
1383        """
1384        Create an :class:`NodeItem` instance and initialize it from a
1385        :class:`SchemeNode` instance.
1386
1387        """
1388        self = cls()
1389        self.setWidgetDescription(node.description)
1390#        self.setCategoryDescription(node.category)
1391        return self
1392
1393    @classmethod
1394    def from_node_meta(cls, meta_description):
1395        """
1396        Create an `NodeItem` instance from a node meta description.
1397        """
1398        self = cls()
1399        self.setWidgetDescription(meta_description)
1400        return self
1401
1402    # TODO: Remove the set[Widget|Category]Description. The user should
1403    # handle setting of icons, title, ...
1404    def setWidgetDescription(self, desc):
1405        # type: (WidgetDescription) -> None
1406        """
1407        Set widget description.
1408        """
1409        self.widget_description = desc
1410        if desc is None:
1411            return
1412
1413        icon = icon_loader.from_description(desc).get(desc.icon)
1414        if icon:
1415            self.setIcon(icon)
1416
1417        if not self.title():
1418            self.setTitle(desc.name)
1419
1420        if desc.inputs:
1421            self.inputAnchorItem.setSignals(desc.inputs)
1422            self.inputAnchorItem.show()
1423        if desc.outputs:
1424            self.outputAnchorItem.setSignals(desc.outputs)
1425            self.outputAnchorItem.show()
1426
1427        tooltip = NodeItem_toolTipHelper(self)
1428        self.setToolTip(tooltip)
1429
1430    def setWidgetCategory(self, desc):
1431        # type: (CategoryDescription) -> None
1432        """
1433        Set the widget category.
1434        """
1435        self.category_description = desc
1436        if desc and desc.background:
1437            background = NAMED_COLORS.get(desc.background, desc.background)
1438            color = QColor(background)
1439            if color.isValid():
1440                self.setColor(color)
1441
1442    def setIcon(self, icon):
1443        # type: (QIcon) -> None
1444        """
1445        Set the node item's icon (:class:`QIcon`).
1446        """
1447        self.icon_item = GraphicsIconItem(
1448            self.shapeItem, icon=icon, iconSize=QSize(36, 36)
1449        )
1450        self.icon_item.setPos(-18, -18)
1451
1452    def setColor(self, color, selectedColor=None):
1453        # type: (QColor, Optional[QColor]) -> None
1454        """
1455        Set the widget color.
1456        """
1457        if selectedColor is None:
1458            selectedColor = saturated(color, 150)
1459        palette = create_palette(color, selectedColor)
1460        self.shapeItem.setPalette(palette)
1461
1462    def setTitle(self, title):
1463        # type: (str) -> None
1464        """
1465        Set the node title. The title text is displayed at the bottom of the
1466        node.
1467        """
1468        if self.__title != title:
1469            self.__title = title
1470            if self.captionTextItem.isEditing():
1471                self.captionTextItem.setPlainText(title)
1472            else:
1473                self.__updateTitleText()
1474
1475    def title(self):
1476        # type: () -> str
1477        """
1478        Return the node title.
1479        """
1480        return self.__title
1481
1482    title_ = Property(str, fget=title, fset=setTitle,
1483                      doc="Node title text.")
1484
1485    #: Title editing has started
1486    titleEditingStarted = Signal()
1487    #: Title editing has finished
1488    titleEditingFinished = Signal()
1489
1490    def editTitle(self):
1491        """
1492        Start the inline title text edit process.
1493        """
1494        if self.captionTextItem.isEditing():
1495            return
1496        self.captionTextItem.setPlainText(self.__title)
1497        self.captionTextItem.selectAll()
1498        self.captionTextItem.setAlignment(Qt.AlignCenter)
1499        self.captionTextItem.document().clearUndoRedoStacks()
1500        self.captionTextItem.editingFinished.connect(self.__editTitleFinish)
1501        self.captionTextItem.edit()
1502        doc = self.captionTextItem.document()
1503        doc.documentLayout().documentSizeChanged.connect(
1504            self.__autoLayoutTitleText, Qt.UniqueConnection
1505        )
1506        self.titleEditingStarted.emit()
1507
1508    def __editTitleFinish(self):
1509        # called when title editing has finished
1510        self.captionTextItem.editingFinished.disconnect(self.__editTitleFinish)
1511        doc = self.captionTextItem.document()
1512        doc.documentLayout().documentSizeChanged.disconnect(
1513            self.__autoLayoutTitleText
1514        )
1515        name = self.captionTextItem.toPlainText()
1516        if name != self.__title:
1517            self.setTitle(name)
1518        self.__updateTitleText()
1519        self.titleEditingFinished.emit()
1520
1521    @Slot()
1522    def __autoLayoutTitleText(self):
1523        # auto layout the title during editing
1524        doc = self.captionTextItem.document()
1525        doc_copy = doc.clone()
1526        doc_copy.adjustSize()
1527        width = doc_copy.textWidth()
1528        doc_copy.deleteLater()
1529        if width == doc.textWidth():
1530            return
1531        self.prepareGeometryChange()
1532        self.__boundingRect = None
1533        with disconnected(
1534                doc.documentLayout().documentSizeChanged,
1535                self.__autoLayoutTitleText
1536        ):
1537            doc.adjustSize()
1538        width = self.captionTextItem.textWidth()
1539        self.captionTextItem.setPos(-width / 2.0, 33)
1540
1541    def setAnimationEnabled(self, enabled):
1542        # type: (bool) -> None
1543        """
1544        Set the node animation enabled state.
1545        """
1546        if self.__animationEnabled != enabled:
1547            self.__animationEnabled = enabled
1548            self.shapeItem.setAnimationEnabled(enabled)
1549            self.outputAnchorItem.setAnimationEnabled(self.__animationEnabled)
1550            self.inputAnchorItem.setAnimationEnabled(self.__animationEnabled)
1551
1552    def animationEnabled(self):
1553        # type: () -> bool
1554        """
1555        Are node animations enabled.
1556        """
1557        return self.__animationEnabled
1558
1559    def setProcessingState(self, state):
1560        # type: (int) -> None
1561        """
1562        Set the node processing state i.e. the node is processing
1563        (is busy) or is idle.
1564        """
1565        if self.__processingState != state:
1566            self.__processingState = state
1567            self.shapeItem.setProcessingState(state)
1568            if not state:
1569                # Clear the progress meter.
1570                self.setProgress(-1)
1571                if self.__animationEnabled:
1572                    self.shapeItem.ping()
1573
1574    def processingState(self):
1575        # type: () -> int
1576        """
1577        The node processing state.
1578        """
1579        return self.__processingState
1580
1581    processingState_ = Property(int, fget=processingState,
1582                                fset=setProcessingState)
1583
1584    def setProgress(self, progress):
1585        # type: (float) -> None
1586        """
1587        Set the node work progress state (number between 0 and 100).
1588        """
1589        if progress is None or progress < 0 or not self.__processingState:
1590            progress = -1.
1591
1592        progress = max(min(progress, 100.), -1.)
1593        if self.__progress != progress:
1594            self.__progress = progress
1595            self.shapeItem.setProgress(progress)
1596            self.__updateTitleText()
1597
1598    def progress(self):
1599        # type: () -> float
1600        """
1601        Return the node work progress state.
1602        """
1603        return self.__progress
1604
1605    progress_ = Property(float, fget=progress, fset=setProgress,
1606                         doc="Node progress state.")
1607
1608    def setStatusMessage(self, message):
1609        # type: (str) -> None
1610        """
1611        Set the node status message text.
1612
1613        This text is displayed below the node's title.
1614        """
1615        if self.__statusMessage != message:
1616            self.__statusMessage = message
1617            self.__updateTitleText()
1618
1619    def statusMessage(self):
1620        # type: () -> str
1621        return self.__statusMessage
1622
1623    def setStateMessage(self, message):
1624        # type: (UserMessage) -> None
1625        """
1626        Set a state message to display over the item.
1627
1628        Parameters
1629        ----------
1630        message : UserMessage
1631            Message to display. `message.severity` is used to determine
1632            the icon and `message.contents` is used as a tool tip.
1633
1634        """
1635        self.__messages[message.message_id] = message
1636        self.__updateMessages()
1637
1638    def setErrorMessage(self, message):
1639        if self.__error != message:
1640            self.__error = message
1641            self.__updateMessages()
1642
1643    def setWarningMessage(self, message):
1644        if self.__warning != message:
1645            self.__warning = message
1646            self.__updateMessages()
1647
1648    def setInfoMessage(self, message):
1649        if self.__info != message:
1650            self.__info = message
1651            self.__updateMessages()
1652
1653    def newInputAnchor(self, signal=None):
1654        # type: (Optional[InputSignal]) -> AnchorPoint
1655        """
1656        Create and return a new input :class:`AnchorPoint`.
1657        """
1658        if not (self.widget_description and self.widget_description.inputs):
1659            raise ValueError("Widget has no inputs.")
1660
1661        anchor = AnchorPoint(self, signal=signal)
1662        self.inputAnchorItem.addAnchor(anchor)
1663
1664        return anchor
1665
1666    def removeInputAnchor(self, anchor):
1667        # type: (AnchorPoint) -> None
1668        """
1669        Remove input anchor.
1670        """
1671        self.inputAnchorItem.removeAnchor(anchor)
1672
1673    def newOutputAnchor(self, signal=None):
1674        # type: (Optional[OutputSignal]) -> AnchorPoint
1675        """
1676        Create and return a new output :class:`AnchorPoint`.
1677        """
1678        if not (self.widget_description and self.widget_description.outputs):
1679            raise ValueError("Widget has no outputs.")
1680
1681        anchor = AnchorPoint(self, signal=signal)
1682        self.outputAnchorItem.addAnchor(anchor)
1683
1684        return anchor
1685
1686    def removeOutputAnchor(self, anchor):
1687        # type: (AnchorPoint) -> None
1688        """
1689        Remove output anchor.
1690        """
1691        self.outputAnchorItem.removeAnchor(anchor)
1692
1693    def inputAnchors(self):
1694        # type: () -> List[AnchorPoint]
1695        """
1696        Return a list of all input anchor points.
1697        """
1698        return self.inputAnchorItem.anchorPoints()
1699
1700    def outputAnchors(self):
1701        # type: () -> List[AnchorPoint]
1702        """
1703        Return a list of all output anchor points.
1704        """
1705        return self.outputAnchorItem.anchorPoints()
1706
1707    def setAnchorRotation(self, angle):
1708        # type: (float) -> None
1709        """
1710        Set the anchor rotation.
1711        """
1712        self.inputAnchorItem.setRotation(angle)
1713        self.outputAnchorItem.setRotation(angle)
1714        self.anchorGeometryChanged.emit()
1715
1716    def anchorRotation(self):
1717        # type: () -> float
1718        """
1719        Return the anchor rotation.
1720        """
1721        return self.inputAnchorItem.rotation()
1722
1723    def boundingRect(self):
1724        # type: () -> QRectF
1725        # TODO: Important because of this any time the child
1726        # items change geometry the self.prepareGeometryChange()
1727        # needs to be called.
1728        if self.__boundingRect is None:
1729            self.__boundingRect = self.childrenBoundingRect()
1730        return QRectF(self.__boundingRect)
1731
1732    def shape(self):
1733        # type: () -> QPainterPath
1734        # Shape for mouse hit detection.
1735        # TODO: Should this return the union of all child items?
1736        return self.shapeItem.shape()
1737
1738    def __updateTitleText(self):
1739        # type: () -> None
1740        """
1741        Update the title text item.
1742        """
1743        if self.captionTextItem.isEditing():
1744            return
1745        text = ['<div align="center">%s' % escape(self.title())]
1746
1747        status_text = []
1748
1749        progress_included = False
1750        if self.__statusMessage:
1751            msg = escape(self.__statusMessage)
1752            format_fields = dict(parse_format_fields(msg))
1753            if "progress" in format_fields and len(format_fields) == 1:
1754                # Insert progress into the status text format string.
1755                spec, _ = format_fields["progress"]
1756                if spec is not None:
1757                    progress_included = True
1758                    progress_str = "{0:.0f}%".format(self.progress())
1759                    status_text.append(msg.format(progress=progress_str))
1760            else:
1761                status_text.append(msg)
1762
1763        if self.progress() >= 0 and not progress_included:
1764            status_text.append("%i%%" % int(self.progress()))
1765
1766        if status_text:
1767            text += ["<br/>",
1768                     '<span style="font-style: italic">',
1769                     "<br/>".join(status_text),
1770                     "</span>"]
1771        text += ["</div>"]
1772        text = "".join(text)
1773        if self.__renderedText != text:
1774            self.__renderedText = text
1775            # The NodeItems boundingRect could change.
1776            self.prepareGeometryChange()
1777            self.__boundingRect = None
1778            self.captionTextItem.setHtml(text)
1779            self.__layoutCaptionTextItem()
1780
1781    def __layoutCaptionTextItem(self):
1782        self.prepareGeometryChange()
1783        self.__boundingRect = None
1784        self.captionTextItem.document().adjustSize()
1785        width = self.captionTextItem.textWidth()
1786        self.captionTextItem.setPos(-width / 2.0, 33)
1787
1788    def __updateMessages(self):
1789        # type: () -> None
1790        """
1791        Update message items (position, visibility and tool tips).
1792        """
1793        items = [self.errorItem, self.warningItem, self.infoItem]
1794
1795        messages = list(self.__messages.values()) + [
1796            UserMessage(self.__error or "", UserMessage.Error,
1797                        message_id="_error"),
1798            UserMessage(self.__warning or "", UserMessage.Warning,
1799                        message_id="_warn"),
1800            UserMessage(self.__info or "", UserMessage.Info,
1801                        message_id="_info"),
1802        ]
1803        key = attrgetter("severity")
1804        messages = groupby(sorted(messages, key=key, reverse=True), key=key)
1805
1806        for (_, message_g), item in zip(messages, items):
1807            message = "<br/>".join(m.contents for m in message_g if m.contents)
1808            item.setVisible(bool(message))
1809            if bool(message):
1810                item.anim.start(QPropertyAnimation.KeepWhenStopped)
1811            item.setToolTip(message or "")
1812
1813        shown = [item for item in items if item.isVisible()]
1814        count = len(shown)
1815        if count:
1816            spacing = 3
1817            rects = [item.boundingRect() for item in shown]
1818            width = sum(rect.width() for rect in rects)
1819            width += spacing * max(0, count - 1)
1820            height = max(rect.height() for rect in rects)
1821            origin = self.shapeItem.boundingRect().top() - spacing - height
1822            origin = QPointF(-width / 2, origin)
1823            for item, rect in zip(shown, rects):
1824                item.setPos(origin)
1825                origin = origin + QPointF(rect.width() + spacing, 0)
1826
1827    def mousePressEvent(self, event):
1828        # type: (QGraphicsSceneMouseEvent) -> None
1829        if self.mousePressTime.elapsed() < QApplication.doubleClickInterval():
1830            # Double-click triggers two mouse press events and a double-click event.
1831            # Ignore the second mouse press event (causes widget's node relocation with
1832            # Logitech's Smart Move).
1833            event.ignore()
1834        else:
1835            self.mousePressTime.restart()
1836            if self.shapeItem.path().contains(event.pos()):
1837                super().mousePressEvent(event)
1838            else:
1839                event.ignore()
1840
1841    def mouseDoubleClickEvent(self, event):
1842        # type: (QGraphicsSceneMouseEvent) -> None
1843        if self.shapeItem.path().contains(event.pos()):
1844            super().mouseDoubleClickEvent(event)
1845            QTimer.singleShot(0, self.activated.emit)
1846        else:
1847            event.ignore()
1848
1849    def contextMenuEvent(self, event):
1850        # type: (QGraphicsSceneContextMenuEvent) -> None
1851        if self.shapeItem.path().contains(event.pos()):
1852            super().contextMenuEvent(event)
1853        else:
1854            event.ignore()
1855
1856    def changeEvent(self, event):
1857        if event.type() == QEvent.PaletteChange:
1858            self.__updatePalette()
1859        elif event.type() == QEvent.FontChange:
1860            self.__updateFont()
1861        super().changeEvent(event)
1862
1863    def itemChange(self, change, value):
1864        # type: (QGraphicsItem.GraphicsItemChange, Any) -> Any
1865        if change == QGraphicsItem.ItemSelectedHasChanged:
1866            self.shapeItem.setSelected(value)
1867            self.captionTextItem.setSelectionState(value)
1868            self.selectedChanged.emit(value)
1869        elif change == QGraphicsItem.ItemPositionHasChanged:
1870            self.positionChanged.emit()
1871        return super().itemChange(change, value)
1872
1873    def __updatePalette(self):
1874        # type: () -> None
1875        palette = self.palette()
1876        self.captionTextItem.setPalette(palette)
1877
1878    def __updateFont(self):
1879        # type: () -> None
1880        self.prepareGeometryChange()
1881        self.captionTextItem.setFont(self.font())
1882        self.__layoutCaptionTextItem()
1883
1884
1885TOOLTIP_TEMPLATE = """\
1886<html>
1887<head>
1888<style type="text/css">
1889{style}
1890</style>
1891</head>
1892<body>
1893{tooltip}
1894</body>
1895</html>
1896"""
1897
1898
1899def NodeItem_toolTipHelper(node, links_in=[], links_out=[]):
1900    # type: (NodeItem, List[LinkItem], List[LinkItem]) -> str
1901    """
1902    A helper function for constructing a standard tooltip for the node
1903    in on the canvas.
1904
1905    Parameters:
1906    ===========
1907    node : NodeItem
1908        The node item instance.
1909    links_in : list of LinkItem instances
1910        A list of input links for the node.
1911    links_out : list of LinkItem instances
1912        A list of output links for the node.
1913
1914    """
1915    desc = node.widget_description
1916    channel_fmt = "<li>{0}</li>"
1917
1918    title_fmt = "<b>{title}</b><hr/>"
1919    title = title_fmt.format(title=escape(node.title()))
1920    inputs_list_fmt = "Inputs:<ul>{inputs}</ul><hr/>"
1921    outputs_list_fmt = "Outputs:<ul>{outputs}</ul>"
1922    if desc.inputs:
1923        inputs = [channel_fmt.format(inp.name) for inp in desc.inputs]
1924        inputs = inputs_list_fmt.format(inputs="".join(inputs))
1925    else:
1926        inputs = "No inputs<hr/>"
1927
1928    if desc.outputs:
1929        outputs = [channel_fmt.format(out.name) for out in desc.outputs]
1930        outputs = outputs_list_fmt.format(outputs="".join(outputs))
1931    else:
1932        outputs = "No outputs"
1933
1934    tooltip = title + inputs + outputs
1935    style = "ul { margin-top: 1px; margin-bottom: 1px; }"
1936    return TOOLTIP_TEMPLATE.format(style=style, tooltip=tooltip)
1937
1938
1939def parse_format_fields(format_str):
1940    # type: (str) -> List[Tuple[str, Tuple[Optional[str], Optional[str]]]]
1941    formatter = string.Formatter()
1942    format_fields = [(field, (spec, conv))
1943                     for _, field, spec, conv in formatter.parse(format_str)
1944                     if field is not None]
1945    return format_fields
1946
1947
1948from .linkitem import LinkItem
1949