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