source: orange/Orange/OrangeCanvas/canvas/items/nodeitem.py @ 11102:1ae099099c23

Revision 11102:1ae099099c23, 22.1 KB checked in by Ales Erjavec <ales.erjavec@…>, 19 months ago (diff)

Added GraphicsItems representing the items in the workflow scheme.

Line 
1"""
2NodeItem
3
4"""
5
6from xml.sax.saxutils import escape
7
8from PyQt4.QtGui import (
9    QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsObject,
10    QGraphicsTextItem, QGraphicsDropShadowEffect,
11    QPen, QBrush, QColor, QPalette, QFont, QIcon, QStyle,
12    QPainter, QPainterPath, QPainterPathStroker
13)
14
15from PyQt4.QtCore import Qt, QPointF, QRectF, QTimer
16from PyQt4.QtCore import pyqtSignal as Signal
17from PyQt4.QtCore import pyqtProperty as Property
18
19from .utils import saturated, radial_gradient, sample_path
20
21from ...registry import NAMED_COLORS
22from ...resources import icon_loader
23
24
25def create_palette(light_color, color):
26    """Return a new `QPalette` from for the NodeShapeItem.
27
28    """
29    palette = QPalette()
30
31    palette.setColor(QPalette.Inactive, QPalette.Light,
32                     saturated(light_color, 50))
33    palette.setColor(QPalette.Inactive, QPalette.Midlight,
34                     saturated(light_color, 90))
35    palette.setColor(QPalette.Inactive, QPalette.Button,
36                     light_color)
37
38    palette.setColor(QPalette.Active, QPalette.Light,
39                     saturated(color, 50))
40    palette.setColor(QPalette.Active, QPalette.Midlight,
41                     saturated(color, 90))
42    palette.setColor(QPalette.Active, QPalette.Button,
43                     color)
44    palette.setColor(QPalette.ButtonText, QColor("#515151"))
45    return palette
46
47
48def default_palette():
49    """Create and return a default palette for a node.
50
51    """
52    return create_palette(QColor(NAMED_COLORS["light-orange"]),
53                          QColor(NAMED_COLORS["orange"]))
54
55
56SHADOW_COLOR = "#9CACB4"
57FOCUS_OUTLINE_COLOR = "#609ED7"
58
59
60class NodeBodyItem(QGraphicsPathItem):
61    """The central part (body) of the `NodeItem`.
62
63    """
64    def __init__(self, parent=None):
65        QGraphicsPathItem.__init__(self, parent)
66        assert(isinstance(parent, NodeItem))
67
68        self.__processingState = 0
69        self.__progress = -1
70        self.__isSelected = False
71        self.__hasFocus = False
72        self.__hover = False
73        self.__shapeRect = QRectF(-10, -10, 20, 20)
74
75        self.setAcceptHoverEvents(True)
76
77        self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, True)
78        self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True)
79
80        self.setPen(QPen(Qt.NoPen))
81
82        self.setPalette(default_palette())
83
84        self.shadow = QGraphicsDropShadowEffect(
85            blurRadius=10,
86            color=QColor(SHADOW_COLOR),
87            offset=QPointF(0, 0),
88            )
89
90        self.setGraphicsEffect(self.shadow)
91        self.shadow.setEnabled(False)
92
93    # TODO: The body item should allow the setting of arbitrary painter
94    # paths (for instance rounded rect, ...)
95    def setShapeRect(self, rect):
96        """Set the shape items `rect`. The item should be confined within
97        this rect.
98
99        """
100        path = QPainterPath()
101        path.addEllipse(rect)
102        self.setPath(path)
103        self.__shapeRect = rect
104
105    def setPalette(self, palette):
106        """Set the shape color palette.
107        """
108        self.palette = palette
109        self.__updateBrush()
110
111    def setProcessingState(self, state):
112        """Set the processing state of the node.
113        """
114        self.__processingState = state
115        self.update()
116
117    def setProgress(self, progress):
118        self.__progress = progress
119        self.update()
120
121    def hoverEnterEvent(self, event):
122        self.__hover = True
123        self.__updateShadowState()
124        return QGraphicsPathItem.hoverEnterEvent(self, event)
125
126    def hoverLeaveEvent(self, event):
127        self.__hover = False
128        self.__updateShadowState()
129        return QGraphicsPathItem.hoverLeaveEvent(self, event)
130
131    def paint(self, painter, option, widget):
132        """Paint the shape and a progress meter.
133        """
134        # Let the default implementation draw the shape
135        if option.state & QStyle.State_Selected:
136            # Prevent the default bounding rect selection indicator.
137            option.state = option.state ^ QStyle.State_Selected
138        QGraphicsPathItem.paint(self, painter, option, widget)
139
140        if self.__progress >= 0:
141            # Draw the progress meter over the shape.
142            # Set the clip to shape so the meter does not overflow the shape.
143            painter.setClipPath(self.shape(), Qt.ReplaceClip)
144            color = self.palette.color(QPalette.ButtonText)
145            pen = QPen(color, 5)
146            painter.save()
147            painter.setPen(pen)
148            painter.setRenderHints(QPainter.Antialiasing)
149            span = int(self.__progress * 57.60)
150            painter.drawArc(self.__shapeRect, 90 * 16, -span)
151            painter.restore()
152
153    def __updateShadowState(self):
154        if self.__hasFocus:
155            color = QColor(FOCUS_OUTLINE_COLOR)
156            self.setPen(QPen(color, 1.5))
157        else:
158            self.setPen(QPen(Qt.NoPen))
159
160        enabled = False
161        if self.__isSelected:
162            self.shadow.setBlurRadius(7)
163            enabled = True
164        elif self.__hover:
165            self.shadow.setBlurRadius(17)
166            enabled = True
167        self.shadow.setEnabled(enabled)
168
169    def __updateBrush(self):
170        palette = self.palette
171        if self.__isSelected:
172            cg = QPalette.Active
173        else:
174            cg = QPalette.Inactive
175
176        palette.setCurrentColorGroup(cg)
177        c1 = palette.color(QPalette.Light)
178        c2 = palette.color(QPalette.Button)
179        grad = radial_gradient(c2, c1)
180        self.setBrush(QBrush(grad))
181
182    # TODO: The selected and focus states should be set using the
183    # QStyle flags (State_Selected. State_HasFocus)
184
185    def setSelected(self, selected):
186        """Set the `selected` state.
187
188        .. note:: The item does not have QGraphicsItem.ItemIsSelectable flag.
189                  This property is instead controlled by the parent NodeItem.
190
191        """
192        self.__isSelected = selected
193        self.__updateBrush()
194
195    def setHasFocus(self, focus):
196        """Set the `has focus` state.
197
198        .. note:: The item does not have QGraphicsItem.ItemIsFocusable flag.
199                  This property is instead controlled by the parent NodeItem.
200        """
201        self.__hasFocus = focus
202        self.__updateShadowState()
203
204
205class NodeAnchorItem(QGraphicsPathItem):
206    """The left/right widget input/output anchors.
207    """
208
209    def __init__(self, parentWidgetItem, *args):
210        QGraphicsPathItem.__init__(self, parentWidgetItem, *args)
211        self.parentWidgetItem = parentWidgetItem
212        self.setAcceptHoverEvents(True)
213        self.setPen(QPen(Qt.NoPen))
214        self.normalBrush = QBrush(QColor("#CDD5D9"))
215        self.connectedBrush = QBrush(QColor("#9CACB4"))
216        self.setBrush(self.normalBrush)
217
218        self.shadow = QGraphicsDropShadowEffect(
219            blurRadius=10,
220            color=QColor(SHADOW_COLOR),
221            offset=QPointF(0, 0)
222        )
223
224        self.setGraphicsEffect(self.shadow)
225        self.shadow.setEnabled(False)
226
227        # Does this item have any anchored links.
228        self.anchored = False
229        self.__fullStroke = None
230        self.__dottedStroke = None
231
232    def setAnchorPath(self, path):
233        """Set the anchor's curve path as a QPainterPath.
234        """
235        self.anchorPath = path
236        # Create a stroke of the path.
237        stroke_path = QPainterPathStroker()
238        stroke_path.setCapStyle(Qt.RoundCap)
239        stroke_path.setWidth(3)
240        # The full stroke
241        self.__fullStroke = stroke_path.createStroke(path)
242
243        # The dotted stroke (when not connected to anything)
244        stroke_path.setDashPattern(Qt.DotLine)
245        self.__dottedStroke = stroke_path.createStroke(path)
246
247        if self.anchored:
248            self.setPath(self.__fullStroke)
249            self.setBrush(self.connectedBrush)
250        else:
251            self.setPath(self.__dottedStroke)
252            self.setBrush(self.normalBrush)
253
254    def setAnchored(self, anchored):
255        """Set the items anchored state.
256        """
257        self.anchored = anchored
258        if anchored:
259            self.setPath(self.__fullStroke)
260            self.setBrush(self.connectedBrush)
261        else:
262            self.setPath(self.__dottedStroke)
263            self.setBrush(self.normalBrush)
264
265    def setConnectionHint(self, hint=None):
266        """Set the connection hint. This can be used to indicate if
267        a connection can be made or not.
268
269        """
270        raise NotImplementedError
271
272    def shape(self):
273        # Use stroke without the doted line (poor mouse cursor collision)
274        if self.__fullStroke is not None:
275            return self.__fullStroke
276        else:
277            return QGraphicsPathItem.shape(self)
278
279    def hoverEnterEvent(self, event):
280        self.shadow.setEnabled(True)
281        return QGraphicsPathItem.hoverEnterEvent(self, event)
282
283    def hoverLeaveEvent(self, event):
284        self.shadow.setEnabled(False)
285        return QGraphicsPathItem.hoverLeaveEvent(self, event)
286
287
288class SourceAnchorItem(NodeAnchorItem):
289    """A source anchor item
290    """
291    pass
292
293
294class SinkAnchorItem(NodeAnchorItem):
295    """A sink anchor item.
296    """
297    pass
298
299
300class AnchorPoint(QGraphicsObject):
301    """A anchor indicator on the WidgetAnchorItem
302    """
303    scenePositionChanged = Signal(QPointF)
304
305    def __init__(self, *args):
306        QGraphicsObject.__init__(self, *args)
307        self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, True)
308        self.setFlag(QGraphicsItem.ItemHasNoContents, True)
309
310    def anchorScenePos(self):
311        """Return anchor position in scene coordinates.
312        """
313        return self.mapToScene(QPointF(0, 0))
314
315    def itemChange(self, change, value):
316        if change == QGraphicsItem.ItemScenePositionHasChanged:
317            self.scenePositionChanged.emit(value.toPointF())
318
319        return QGraphicsObject.itemChange(self, change, value)
320
321    def boundingRect(self,):
322        return QRectF(0, 0, 1, 1)
323
324
325class NodeItem(QGraphicsObject):
326    """An widget node item in the canvas.
327    """
328
329    positionChanged = Signal()
330    """Position of the node on the canvas changed"""
331
332    anchorGeometryChanged = Signal()
333    """Geometry of the channel anchors changed"""
334
335    activated = Signal()
336    """The item has been activated (by a mouse double click or a keyboard)"""
337
338    hovered = Signal()
339    """The item is under the mouse."""
340
341    ANCHOR_SPAN_ANGLE = 90
342    """Span of the anchor in degrees"""
343
344    Z_VALUE = 100
345    """Z value of the item"""
346
347    def __init__(self, widget_description=None, parent=None, **kwargs):
348        QGraphicsObject.__init__(self, parent, **kwargs)
349        self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True)
350        self.setFlag(QGraphicsItem.ItemHasNoContents, True)
351        self.setFlag(QGraphicsItem.ItemIsSelectable, True)
352        self.setFlag(QGraphicsItem.ItemIsMovable, True)
353        self.setFlag(QGraphicsItem.ItemIsFocusable, True)
354
355        # round anchor indicators on the anchor path
356        self.inputAnchors = []
357        self.outputAnchors = []
358
359        self.__title = ""
360        self.__processingState = 0
361        self.__progress = -1
362
363        self.setZValue(self.Z_VALUE)
364        self.setupGraphics()
365
366        self.setWidgetDescription(widget_description)
367
368    @classmethod
369    def from_node(cls, node):
370        """Create an `NodeItem` instance and initialize it from an
371        `SchemeNode` instance.
372
373        """
374        self = cls()
375        self.setWidgetDescription(node.description)
376#        self.setCategoryDescription(node.category)
377        return self
378
379    @classmethod
380    def from_node_meta(cls, meta_description):
381        """Create an `NodeItem` instance from a node meta description.
382        """
383        self = cls()
384        self.setWidgetDescription(meta_description)
385        return self
386
387    def setupGraphics(self):
388        """Set up the graphics.
389        """
390        shape_rect = QRectF(-24, -24, 48, 48)
391
392        self.shapeItem = NodeBodyItem(self)
393        self.shapeItem.setShapeRect(shape_rect)
394
395        # Rect for widget's 'ears'.
396        anchor_rect = QRectF(-31, -31, 62, 62)
397        self.inputAnchorItem = SinkAnchorItem(self)
398        input_path = QPainterPath()
399        start_angle = 180 - self.ANCHOR_SPAN_ANGLE / 2
400        input_path.arcMoveTo(anchor_rect, start_angle)
401        input_path.arcTo(anchor_rect, start_angle, self.ANCHOR_SPAN_ANGLE)
402        self.inputAnchorItem.setAnchorPath(input_path)
403
404        self.outputAnchorItem = SourceAnchorItem(self)
405        output_path = QPainterPath()
406        start_angle = self.ANCHOR_SPAN_ANGLE / 2
407        output_path.arcMoveTo(anchor_rect, start_angle)
408        output_path.arcTo(anchor_rect, start_angle, - self.ANCHOR_SPAN_ANGLE)
409        self.outputAnchorItem.setAnchorPath(output_path)
410
411        self.inputAnchorItem.hide()
412        self.outputAnchorItem.hide()
413
414        # Title caption item
415        self.captionTextItem = QGraphicsTextItem(self)
416        self.captionTextItem.setPlainText("")
417        self.captionTextItem.setPos(0, 33)
418        font = QFont("Helvetica", 12)
419        self.captionTextItem.setFont(font)
420
421    def setWidgetDescription(self, desc):
422        """Set widget description.
423        """
424        self.widget_description = desc
425        if desc is None:
426            return
427
428        icon = icon_loader.from_description(desc).get(desc.icon)
429        if icon:
430            self.setIcon(icon)
431
432        if not self.title():
433            self.setTitle(desc.name)
434
435        if desc.inputs:
436            self.inputAnchorItem.show()
437        if desc.outputs:
438            self.outputAnchorItem.show()
439
440        tooltip = NodeItem_toolTipHelper(self)
441        self.setToolTip(tooltip)
442
443    def setWidgetCategory(self, desc):
444        self.category_description = desc
445        if desc and desc.background:
446            background = NAMED_COLORS.get(desc.background, desc.background)
447            color = QColor(background)
448            if color.isValid():
449                self.setColor(color)
450
451    def setIcon(self, icon):
452        """Set the widget's icon
453        """
454        # TODO: if the icon is SVG, how can we get it?
455        if isinstance(icon, QIcon):
456            pixmap = icon.pixmap(36, 36)
457            self.pixmap_item = QGraphicsPixmapItem(pixmap, self.shapeItem)
458            self.pixmap_item.setPos(-18, -18)
459        else:
460            raise TypeError
461
462    def setColor(self, color, selectedColor=None):
463        """Set the widget color.
464        """
465        if selectedColor is None:
466            selectedColor = saturated(color, 150)
467        palette = create_palette(color, selectedColor)
468#        gradient = radial_gradient(color, selectedColor)
469#        self.shapeItem.setBrush(QBrush(gradient))
470        self.shapeItem.setPalette(palette)
471
472    def setPalette(self):
473        """
474        """
475        pass
476
477    def setTitle(self, title):
478        """Set the widget title.
479        """
480        self.__title = title
481        self.__updateTitleText()
482
483    def title(self):
484        return self.__title
485
486    title_ = Property(unicode, fget=title, fset=setTitle)
487
488    def setProcessingState(self, state):
489        """Set the node processing state i.e. the node is processing
490        (is busy) or is idle.
491
492        """
493        if self.__processingState != state:
494            self.__processingState = state
495            self.shapeItem.setProcessingState(state)
496            if not state:
497                # Clear the progress meter.
498                self.setProgress(-1)
499
500    def processingState(self):
501        return self.__processingState
502
503    processingState_ = Property(int, fget=processingState,
504                                fset=setProcessingState)
505
506    def setProgress(self, progress):
507        """Set the node work progress indicator.
508        """
509        if progress is None or progress < 0:
510            progress = -1
511
512        progress = max(min(progress, 100), -1)
513        if self.__progress != progress:
514            self.__progress = progress
515            self.shapeItem.setProgress(progress)
516            self.__updateTitleText()
517
518    def progress(self):
519        return self.__progress
520
521    progress_ = Property(float, fget=progress, fset=setProgress)
522
523    def setProgressMessage(self, message):
524        """Set the node work progress message.
525        """
526        pass
527
528    def setErrorMessage(self, message):
529        pass
530
531    def setWarningMessage(self, message):
532        pass
533
534    def setInformationMessage(self, message):
535        pass
536
537    def newInputAnchor(self):
538        """Create and return a new input anchor point.
539        """
540        if not (self.widget_description and self.widget_description.inputs):
541            raise ValueError("Widget has no inputs.")
542
543        anchor = AnchorPoint(self)
544        self.inputAnchors.append(anchor)
545
546        self._layoutAnchors(self.inputAnchors,
547                            self.inputAnchorItem.anchorPath)
548
549        self.inputAnchorItem.setAnchored(bool(self.inputAnchors))
550        return anchor
551
552    def removeInputAnchor(self, anchor):
553        """Remove input anchor.
554        """
555        self.inputAnchors.remove(anchor)
556        anchor.setParentItem(None)
557
558        if anchor.scene():
559            anchor.scene().removeItem(anchor)
560
561        self._layoutAnchors(self.inputAnchors,
562                            self.inputAnchorItem.anchorPath)
563
564        self.inputAnchorItem.setAnchored(bool(self.inputAnchors))
565
566    def newOutputAnchor(self):
567        """Create a new output anchor indicator.
568        """
569        if not (self.widget_description and self.widget_description.outputs):
570            raise ValueError("Widget has no outputs.")
571
572        anchor = AnchorPoint(self)
573
574        self.outputAnchors.append(anchor)
575
576        self._layoutAnchors(self.outputAnchors,
577                            self.outputAnchorItem.anchorPath)
578
579        self.outputAnchorItem.setAnchored(bool(self.outputAnchors))
580        return anchor
581
582    def removeOutputAnchor(self, anchor):
583        """Remove output anchor.
584        """
585        self.outputAnchors.remove(anchor)
586        anchor.hide()
587        anchor.setParentItem(None)
588
589        if anchor.scene():
590            anchor.scene().removeItem(anchor)
591
592        self._layoutAnchors(self.outputAnchors,
593                            self.outputAnchorItem.anchorPath)
594
595        self.outputAnchorItem.setAnchored(bool(self.outputAnchors))
596
597    def _layoutAnchors(self, anchors, path):
598        """Layout `anchors` on the `path`.
599        TODO: anchor reordering (spring force optimization?).
600
601        """
602        n_points = len(anchors) + 2
603        if anchors:
604            points = sample_path(path, n_points)
605            for p, anchor in zip(points[1:-1], anchors):
606                anchor.setPos(p)
607
608    def boundingRect(self):
609        # TODO: Important because of this any time the child
610        # items change geometry the self.prepareGeometryChange()
611        # needs to be called.
612        return self.childrenBoundingRect()
613
614    def shape(self):
615        """Reimplemented: Return the shape of the 'shapeItem', This is used
616        for hit testing in QGraphicsScene.
617
618        """
619        # Should this return the union of all child items?
620        return self.shapeItem.shape()
621
622#    def _delegate(self, event):
623#        """Called by child items. Delegate the event actions to the
624#        appropriate actions.
625#
626#        """
627#        if event == "mouseDoubleClickEvent":
628#            self.activated.emit()
629#        elif event == "hoverEnterEvent":
630#            self.hovered.emit()
631
632    def __updateTitleText(self):
633        """Update the title text item.
634        """
635        title_safe = escape(self.title())
636        if self.progress() > 0:
637            text = '<div align="center">%s<br/>%i%%</div>' % \
638                   (title_safe, int(self.progress()))
639        else:
640            text = '<div align="center">%s</div>' % \
641                   (title_safe)
642
643        # The NodeItems boundingRect could change.
644        self.prepareGeometryChange()
645        self.captionTextItem.setHtml(text)
646        self.captionTextItem.document().adjustSize()
647        width = self.captionTextItem.textWidth()
648        self.captionTextItem.setPos(-width / 2.0, 33)
649
650    def mousePressEvent(self, event):
651        if self.shapeItem.path().contains(event.pos()):
652            return QGraphicsObject.mousePressEvent(self, event)
653        else:
654            event.ignore()
655
656    def mouseDoubleClickEvent(self, event):
657        if self.shapeItem.path().contains(event.pos()):
658            QGraphicsObject.mouseDoubleClickEvent(self, event)
659            QTimer.singleShot(0, self.activated.emit)
660        else:
661            event.ignore()
662
663    def contextMenuEvent(self, event):
664        if self.shapeItem.path().contains(event.pos()):
665            return QGraphicsObject.contextMenuEvent(self, event)
666        else:
667            event.ignore()
668
669    def focusInEvent(self, event):
670        self.shapeItem.setHasFocus(True)
671        return QGraphicsObject.focusInEvent(self, event)
672
673    def focusOutEvent(self, event):
674        self.shapeItem.setHasFocus(False)
675        return QGraphicsObject.focusOutEvent(self, event)
676
677    def itemChange(self, change, value):
678        if change == QGraphicsItem.ItemSelectedChange:
679            self.shapeItem.setSelected(value.toBool())
680        elif change == QGraphicsItem.ItemPositionHasChanged:
681            self.positionChanged.emit()
682
683        return QGraphicsObject.itemChange(self, change, value)
684
685
686TOOLTIP_TEMPLATE = """\
687<html>
688<head>
689<style type="text/css">
690{style}
691</style>
692</head>
693<body>
694{tooltip}
695</body>
696</html>
697"""
698
699
700def NodeItem_toolTipHelper(node, links_in=[], links_out=[]):
701    """A helper function for constructing a standard tooltop for the node
702    in on the canvas.
703
704    Parameters:
705    ===========
706    node : NodeItem
707        The node item instance.
708    links_in : list of LinkItem instances
709        A list of input links for the node.
710    links_out : list of LinkItem instances
711        A list of output links for the node.
712
713    """
714    desc = node.widget_description
715    channel_fmt = "<li>{0}</li>"
716
717    title_fmt = "<b>{title}</b><hr/>"
718    title = title_fmt.format(title=escape(node.title()))
719    inputs_list_fmt = "Inputs:<ul>{inputs}</ul><hr/>"
720    outputs_list_fmt = "Outputs:<ul>{outputs}</ul>"
721    inputs = outputs = ["None"]
722    if desc.inputs:
723        inputs = [channel_fmt.format(inp.name) for inp in desc.inputs]
724
725    if desc.outputs:
726        outputs = [channel_fmt.format(out.name) for out in desc.outputs]
727
728    inputs = inputs_list_fmt.format(inputs="".join(inputs))
729    outputs = outputs_list_fmt.format(outputs="".join(outputs))
730    tooltip = title + inputs + outputs
731    style = "ul { margin-top: 1px; margin-bottom: 1px; }"
732    return TOOLTIP_TEMPLATE.format(style=style, tooltip=tooltip)
Note: See TracBrowser for help on using the repository browser.