Example #1
0
class SplitterResizer(QObject):
    """
    An object able to control the size of a widget in a QSplitter instance.
    """
    def __init__(self, parent=None, **kwargs):
        # type: (Optional[QObject], Any) -> None
        super().__init__(parent, **kwargs)
        self.__splitter = None  # type: Optional[QSplitter]
        self.__widget = None    # type: Optional[QWidget]
        self.__updateOnShow = True  # Need __update on next show event
        self.__animationEnabled = True
        self.__size = -1
        self.__expanded = False
        self.__animation = QPropertyAnimation(
            self, b"size_", self, duration=200
        )
        self.__action = QAction("toggle-expanded", self, checkable=True)
        self.__action.triggered[bool].connect(self.setExpanded)

    def setSize(self, size):
        # type: (int) -> None
        """
        Set the size of the controlled widget (either width or height
        depending on the orientation).

        .. note::
            The controlled widget's size is only updated when it it is shown.
        """
        if self.__size != size:
            self.__size = size
            self.__update()

    def size(self):
        # type: () -> int
        """
        Return the size of the widget in the splitter (either height of
        width) depending on the splitter orientation.
        """
        if self.__splitter and self.__widget:
            index = self.__splitter.indexOf(self.__widget)
            sizes = self.__splitter.sizes()
            return sizes[index]
        else:
            return -1

    size_ = Property(int, fget=size, fset=setSize)

    def setAnimationEnabled(self, enable):
        # type: (bool) -> None
        """Enable/disable animation."""
        self.__animation.setDuration(0 if enable else 200)

    def animationEnabled(self):
        # type: () -> bool
        return self.__animation.duration() == 0

    def setSplitterAndWidget(self, splitter, widget):
        # type: (QSplitter, QWidget) -> None
        """Set the QSplitter and QWidget instance the resizer should control.

        .. note:: the widget must be in the splitter.
        """
        if splitter and widget and not splitter.indexOf(widget) > 0:
            raise ValueError("Widget must be in a splitter.")

        if self.__widget is not None:
            self.__widget.removeEventFilter(self)
        if self.__splitter is not None:
            self.__splitter.removeEventFilter(self)

        self.__splitter = splitter
        self.__widget = widget

        if widget is not None:
            widget.installEventFilter(self)
        if splitter is not None:
            splitter.installEventFilter(self)

        self.__update()

        size = self.size()
        if self.__expanded and size == 0:
            self.open()
        elif not self.__expanded and size > 0:
            self.close()

    def toggleExpandedAction(self):
        # type: () -> QAction
        """Return a QAction that can be used to toggle expanded state.
        """
        return self.__action

    def toogleExpandedAction(self):
        warnings.warn(
            "'toogleExpandedAction is deprecated, use 'toggleExpandedAction' "
            "instead.", DeprecationWarning, stacklevel=2
        )
        return self.toggleExpandedAction()

    def open(self):
        # type: () -> None
        """Open the controlled widget (expand it to sizeHint).
        """
        self.__expanded = True
        self.__action.setChecked(True)

        if self.__splitter is None or self.__widget is None:
            return

        hint = self.__widget.sizeHint()

        if self.__splitter.orientation() == Qt.Vertical:
            end = hint.height()
        else:
            end = hint.width()

        self.__animation.setStartValue(0)
        self.__animation.setEndValue(end)
        self.__animation.start()

    def close(self):
        # type: () -> None
        """Close the controlled widget (shrink to size 0).
        """
        self.__expanded = False
        self.__action.setChecked(False)

        if self.__splitter is None or self.__widget is None:
            return

        self.__animation.setStartValue(self.size())
        self.__animation.setEndValue(0)
        self.__animation.start()

    def setExpanded(self, expanded):
        # type: (bool) -> None
        """Set the expanded state."""
        if self.__expanded != expanded:
            if expanded:
                self.open()
            else:
                self.close()

    def expanded(self):
        # type: () -> bool
        """Return the expanded state."""
        return self.__expanded

    def __update(self):
        # type: () -> None
        """Update the splitter sizes."""
        if self.__splitter and self.__widget:
            if sum(self.__splitter.sizes()) == 0:
                # schedule update on next show event
                self.__updateOnShow = True
                return

            splitter = self.__splitter
            index = splitter.indexOf(self.__widget)
            sizes = splitter.sizes()
            current = sizes[index]
            diff = current - self.__size
            sizes[index] = self.__size
            sizes[index - 1] = sizes[index - 1] + diff
            self.__splitter.setSizes(sizes)

    def eventFilter(self, obj, event):
        # type: (QObject, QEvent) -> bool
        if event.type() == QEvent.Resize and obj is self.__widget and \
                self.__animation.state() == QPropertyAnimation.Stopped:
            # Update the expanded state when the user opens/closes the widget
            # by dragging the splitter handle.
            assert self.__splitter is not None
            assert isinstance(event, QResizeEvent)
            if self.__splitter.orientation() == Qt.Vertical:
                size = event.size().height()
            else:
                size = event.size().width()

            if self.__expanded and size == 0:
                self.__action.setChecked(False)
                self.__expanded = False
            elif not self.__expanded and size > 0:
                self.__action.setChecked(True)
                self.__expanded = True

        if event.type() == QEvent.Show and obj is self.__splitter and \
                self.__updateOnShow:
            # Update the splitter state after receiving valid geometry
            self.__updateOnShow = False
            self.__update()
        return super().eventFilter(obj, event)
Example #2
0
class AnimatedStackedWidget(QFrame):
    # Current widget has changed
    currentChanged = Signal(int)

    # Transition animation has started
    transitionStarted = Signal()

    # Transition animation has finished
    transitionFinished = Signal()

    def __init__(self, parent=None, animationEnabled=True):
        QFrame.__init__(self, parent)
        self.__animationEnabled = animationEnabled

        layout = StackLayout()

        self.__fadeWidget = CrossFadePixmapWidget(self)

        self.transitionAnimation = \
            QPropertyAnimation(self.__fadeWidget, b"blendingFactor_", self)
        self.transitionAnimation.setStartValue(0.0)
        self.transitionAnimation.setEndValue(1.0)
        self.transitionAnimation.setDuration(100 if animationEnabled else 0)
        self.transitionAnimation.finished.connect(self.__onTransitionFinished)

        layout.addWidget(self.__fadeWidget)
        layout.currentChanged.connect(self.__onLayoutCurrentChanged)

        self.setLayout(layout)

        self.__widgets = []
        self.__currentIndex = -1
        self.__nextCurrentIndex = -1

    def setAnimationEnabled(self, animationEnabled):
        """
        Enable/disable transition animations.
        """
        if self.__animationEnabled != animationEnabled:
            self.__animationEnabled = animationEnabled
            self.transitionAnimation.setDuration(
                100 if animationEnabled else 0)

    def animationEnabled(self):
        """
        Is the transition animation enabled.
        """
        return self.__animationEnabled

    def addWidget(self, widget):
        """
        Append the widget to the stack and return its index.
        """
        return self.insertWidget(self.layout().count(), widget)

    def insertWidget(self, index, widget):
        """
        Insert `widget` into the stack at `index`.
        """
        index = min(index, self.count())
        self.__widgets.insert(index, widget)
        if index <= self.__currentIndex or self.__currentIndex == -1:
            self.__currentIndex += 1
        return self.layout().insertWidget(index, widget)

    def removeWidget(self, widget):
        """
        Remove `widget` from the stack.

        .. note:: The widget is hidden but is not deleted.

        """
        index = self.__widgets.index(widget)
        self.layout().removeWidget(widget)
        self.__widgets.pop(index)

    def widget(self, index):
        """
        Return the widget at `index`
        """
        return self.__widgets[index]

    def indexOf(self, widget):
        """
        Return the index of `widget` in the stack.
        """
        return self.__widgets.index(widget)

    def count(self):
        """
        Return the number of widgets in the stack.
        """
        return max(self.layout().count() - 1, 0)

    def setCurrentWidget(self, widget):
        """
        Set the current shown widget.
        """
        index = self.__widgets.index(widget)
        self.setCurrentIndex(index)

    def setCurrentIndex(self, index):
        """
        Set the current shown widget index.
        """
        index = max(min(index, self.count() - 1), 0)
        if self.__currentIndex == -1:
            self.layout().setCurrentIndex(index)
            self.__currentIndex = index
            return


#        if not self.animationEnabled():
#            self.layout().setCurrentIndex(index)
#            self.__currentIndex = index
#            return

# else start the animation
        current = self.__widgets[self.__currentIndex]
        next_widget = self.__widgets[index]

        def has_pending_resize(widget):
            return widget.testAttribute(Qt.WA_PendingResizeEvent) or \
                   not widget.testAttribute(Qt.WA_WState_Created)

        current_pix = next_pix = None
        if not has_pending_resize(current):
            current_pix = current.grab()
        if not has_pending_resize(next_widget):
            next_pix = next_widget.grab()

        with updates_disabled(self):
            self.__fadeWidget.setPixmap(current_pix)
            self.__fadeWidget.setPixmap2(next_pix)
            self.__nextCurrentIndex = index
            self.__transitionStart()

    def currentIndex(self):
        """
        Return the current shown widget index.
        """
        return self.__currentIndex

    def sizeHint(self):
        hint = QFrame.sizeHint(self)
        if hint.isEmpty():
            hint = QSize(0, 0)
        return hint

    def __transitionStart(self):
        """
        Start the transition.
        """
        log.debug("Stack transition start (%s)", str(self.objectName()))
        # Set the fade widget as the current widget
        self.__fadeWidget.blendingFactor_ = 0.0
        self.layout().setCurrentWidget(self.__fadeWidget)
        self.transitionAnimation.start()
        self.transitionStarted.emit()

    def __onTransitionFinished(self):
        """
        Transition has finished.
        """
        log.debug("Stack transition finished (%s)" % str(self.objectName()))
        self.__fadeWidget.blendingFactor_ = 1.0
        self.__currentIndex = self.__nextCurrentIndex
        with updates_disabled(self):
            self.layout().setCurrentIndex(self.__currentIndex)
        self.transitionFinished.emit()

    def __onLayoutCurrentChanged(self, index):
        # Suppress transitional __fadeWidget current widget
        if index != self.count():
            self.currentChanged.emit(index)
Example #3
0
class NodeBodyItem(GraphicsPathObject):
    """
    The central part (body) of the `NodeItem`.
    """
    def __init__(self, parent=None):
        GraphicsPathObject.__init__(self, parent)
        assert (isinstance(parent, NodeItem))

        self.__processingState = 0
        self.__progress = -1
        self.__animationEnabled = False
        self.__isSelected = False
        self.__hasFocus = False
        self.__hover = False
        self.__shapeRect = QRectF(-10, -10, 20, 20)

        self.setAcceptHoverEvents(True)

        self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, True)
        self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True)

        self.setPen(QPen(Qt.NoPen))

        self.setPalette(default_palette())

        self.shadow = QGraphicsDropShadowEffect(
            blurRadius=3,
            color=QColor(SHADOW_COLOR),
            offset=QPointF(0, 0),
        )
        self.shadow.setEnabled(True)

        # An item with the same shape as this object, stacked behind this
        # item as a source for QGraphicsDropShadowEffect. Cannot attach
        # the effect to this item directly as QGraphicsEffect makes the item
        # non devicePixelRatio aware.
        shadowitem = GraphicsPathObject(self, objectName="shadow-shape-item")
        shadowitem.setPen(Qt.NoPen)
        shadowitem.setBrush(QBrush(QColor(SHADOW_COLOR).lighter()))
        shadowitem.setGraphicsEffect(self.shadow)
        shadowitem.setFlag(QGraphicsItem.ItemStacksBehindParent)
        self.__shadow = shadowitem
        self.__blurAnimation = QPropertyAnimation(self.shadow, b"blurRadius",
                                                  self)
        self.__blurAnimation.setDuration(100)
        self.__blurAnimation.finished.connect(self.__on_finished)

        self.__pingAnimation = QPropertyAnimation(self, b"scale", self)
        self.__pingAnimation.setDuration(250)
        self.__pingAnimation.setKeyValues([(0.0, 1.0), (0.5, 1.1), (1.0, 1.0)])

    # TODO: The body item should allow the setting of arbitrary painter
    # paths (for instance rounded rect, ...)
    def setShapeRect(self, rect):
        """
        Set the item's shape `rect`. The item should be confined within
        this rect.

        """
        path = QPainterPath()
        path.addEllipse(rect)
        self.setPath(path)
        self.__shadow.setPath(path)
        self.__shapeRect = rect

    def setPalette(self, palette):
        """
        Set the body color palette (:class:`QPalette`).
        """
        self.palette = palette
        self.__updateBrush()

    def setAnimationEnabled(self, enabled):
        """
        Set the node animation enabled.
        """
        if self.__animationEnabled != enabled:
            self.__animationEnabled = enabled

    def setProcessingState(self, state):
        """
        Set the processing state of the node.
        """
        if self.__processingState != state:
            self.__processingState = state
            if not state and self.__animationEnabled:
                self.ping()

    def setProgress(self, progress):
        """
        Set the progress indicator state of the node. `progress` should
        be a number between 0 and 100.

        """
        self.__progress = progress
        self.update()

    def ping(self):
        """
        Trigger a 'ping' animation.
        """
        animation_restart(self.__pingAnimation)

    def hoverEnterEvent(self, event):
        self.__hover = True
        self.__updateShadowState()
        return GraphicsPathObject.hoverEnterEvent(self, event)

    def hoverLeaveEvent(self, event):
        self.__hover = False
        self.__updateShadowState()
        return GraphicsPathObject.hoverLeaveEvent(self, event)

    def paint(self, painter, option, widget):
        """
        Paint the shape and a progress meter.
        """
        # Let the default implementation draw the shape
        if option.state & QStyle.State_Selected:
            # Prevent the default bounding rect selection indicator.
            option.state = option.state ^ QStyle.State_Selected
        GraphicsPathObject.paint(self, painter, option, widget)
        if self.__progress >= 0:
            # Draw the progress meter over the shape.
            # Set the clip to shape so the meter does not overflow the shape.
            painter.save()
            painter.setClipPath(self.shape(), Qt.ReplaceClip)
            color = self.palette.color(QPalette.ButtonText)
            pen = QPen(color, 5)
            painter.setPen(pen)
            painter.setRenderHints(QPainter.Antialiasing)
            span = max(1, int(self.__progress * 57.60))
            painter.drawArc(self.__shapeRect, 90 * 16, -span)
            painter.restore()

    def __updateShadowState(self):
        if self.__hasFocus:
            color = QColor(FOCUS_OUTLINE_COLOR)
            self.setPen(QPen(color, 1.5))
        else:
            self.setPen(QPen(Qt.NoPen))

        radius = 3
        enabled = False

        if self.__isSelected:
            enabled = True
            radius = 7

        if self.__hover:
            radius = 17
            enabled = True

        if enabled and not self.shadow.isEnabled():
            self.shadow.setEnabled(enabled)

        if self.__animationEnabled:
            if self.__blurAnimation.state() == QPropertyAnimation.Running:
                self.__blurAnimation.pause()

            self.__blurAnimation.setStartValue(self.shadow.blurRadius())
            self.__blurAnimation.setEndValue(radius)
            self.__blurAnimation.start()
        else:
            self.shadow.setBlurRadius(radius)

    def __updateBrush(self):
        palette = self.palette
        if self.__isSelected:
            cg = QPalette.Active
        else:
            cg = QPalette.Inactive

        palette.setCurrentColorGroup(cg)
        c1 = palette.color(QPalette.Light)
        c2 = palette.color(QPalette.Button)
        grad = radial_gradient(c2, c1)
        self.setBrush(QBrush(grad))

    # TODO: The selected and focus states should be set using the
    # QStyle flags (State_Selected. State_HasFocus)

    def setSelected(self, selected):
        """
        Set the `selected` state.

        .. note:: The item does not have `QGraphicsItem.ItemIsSelectable` flag.
                  This property is instead controlled by the parent NodeItem.

        """
        self.__isSelected = selected
        self.__updateBrush()

    def setHasFocus(self, focus):
        """
        Set the `has focus` state.

        .. note:: The item does not have `QGraphicsItem.ItemIsFocusable` flag.
                  This property is instead controlled by the parent NodeItem.

        """
        self.__hasFocus = focus
        self.__updateShadowState()

    def __on_finished(self):
        if self.shadow.blurRadius() == 0:
            self.shadow.setEnabled(False)
Example #4
0
class LinkCurveItem(QGraphicsPathItem):
    """
    Link curve item. The main component of a :class:`LinkItem`.
    """
    def __init__(self, parent):
        # type: (QGraphicsItem) -> None
        super().__init__(parent)
        self.setAcceptedMouseButtons(Qt.NoButton)
        self.setAcceptHoverEvents(True)

        self.__animationEnabled = False
        self.__hover = False
        self.__enabled = True
        self.__shape = None  # type: Optional[QPainterPath]
        self.__curvepath = QPainterPath()
        self.__curvepath_disabled = None  # type: Optional[QPainterPath]
        self.__pen = self.pen()
        self.setPen(QPen(QBrush(QColor("#9CACB4")), 2.0))

        self.shadow = QGraphicsDropShadowEffect(blurRadius=5,
                                                color=QColor(SHADOW_COLOR),
                                                offset=QPointF(0, 0))
        self.setGraphicsEffect(self.shadow)
        self.shadow.setEnabled(False)

        self.__blurAnimation = QPropertyAnimation(self.shadow, b"blurRadius")
        self.__blurAnimation.setDuration(50)
        self.__blurAnimation.finished.connect(self.__on_finished)

    def setCurvePath(self, path):
        # type: (QPainterPath) -> None
        if path != self.__curvepath:
            self.prepareGeometryChange()
            self.__curvepath = QPainterPath(path)
            self.__curvepath_disabled = None
            self.__shape = None
            self.__update()

    def curvePath(self):
        # type: () -> QPainterPath
        return QPainterPath(self.__curvepath)

    def setHoverState(self, state):
        # type: (bool) -> None
        self.prepareGeometryChange()
        self.__hover = state
        self.__update()

    def setLinkEnabled(self, state):
        # type: (bool) -> None
        self.prepareGeometryChange()
        self.__enabled = state
        self.__update()

    def isLinkEnabled(self):
        # type: () -> bool
        return self.__enabled

    def setPen(self, pen):
        # type: (QPen) -> None
        if self.__pen != pen:
            self.prepareGeometryChange()
            self.__pen = QPen(pen)
            self.__shape = None
            super().setPen(self.__pen)

    def shape(self):
        # type: () -> QPainterPath
        if self.__shape is None:
            path = self.curvePath()
            pen = QPen(self.pen())
            pen.setWidthF(max(pen.widthF(), 25.0))
            pen.setStyle(Qt.SolidLine)
            self.__shape = stroke_path(path, pen)
        return self.__shape

    def setPath(self, path):
        # type: (QPainterPath) -> None
        self.__shape = None
        super().setPath(path)

    def setAnimationEnabled(self, enabled):
        # type: (bool) -> None
        """
        Set the link item animation enabled.
        """
        if self.__animationEnabled != enabled:
            self.__animationEnabled = enabled

    def __update(self):
        # type: () -> None
        radius = 5 if self.__hover else 0

        if radius != 0 and not self.shadow.isEnabled():
            self.shadow.setEnabled(True)

        if self.__animationEnabled:
            if self.__blurAnimation.state() == QPropertyAnimation.Running:
                self.__blurAnimation.pause()

            self.__blurAnimation.setStartValue(self.shadow.blurRadius())
            self.__blurAnimation.setEndValue(radius)
            self.__blurAnimation.start()
        else:
            self.shadow.setBlurRadius(radius)

        basecurve = self.__curvepath
        link_enabled = self.__enabled
        if link_enabled:
            path = basecurve
        else:
            if self.__curvepath_disabled is None:
                self.__curvepath_disabled = path_link_disabled(basecurve)
            path = self.__curvepath_disabled

        self.setPath(path)

    def __on_finished(self):
        if self.shadow.blurRadius() == 0:
            self.shadow.setEnabled(False)
Example #5
0
class AnimatedStackedWidget(QFrame):
    # Current widget has changed
    currentChanged = Signal(int)

    # Transition animation has started
    transitionStarted = Signal()

    # Transition animation has finished
    transitionFinished = Signal()

    def __init__(self, parent=None, animationEnabled=True):
        QFrame.__init__(self, parent)
        self.__animationEnabled = animationEnabled

        layout = StackLayout()

        self.__fadeWidget = CrossFadePixmapWidget(self)

        self.transitionAnimation = \
            QPropertyAnimation(self.__fadeWidget, b"blendingFactor_", self)
        self.transitionAnimation.setStartValue(0.0)
        self.transitionAnimation.setEndValue(1.0)
        self.transitionAnimation.setDuration(100 if animationEnabled else 0)
        self.transitionAnimation.finished.connect(
            self.__onTransitionFinished
        )

        layout.addWidget(self.__fadeWidget)
        layout.currentChanged.connect(self.__onLayoutCurrentChanged)

        self.setLayout(layout)

        self.__widgets = []
        self.__currentIndex = -1
        self.__nextCurrentIndex = -1

    def setAnimationEnabled(self, animationEnabled):
        """
        Enable/disable transition animations.
        """
        if self.__animationEnabled != animationEnabled:
            self.__animationEnabled = animationEnabled
            self.transitionAnimation.setDuration(
                100 if animationEnabled else 0
            )

    def animationEnabled(self):
        """
        Is the transition animation enabled.
        """
        return self.__animationEnabled

    def addWidget(self, widget):
        """
        Append the widget to the stack and return its index.
        """
        return self.insertWidget(self.layout().count(), widget)

    def insertWidget(self, index, widget):
        """
        Insert `widget` into the stack at `index`.
        """
        index = min(index, self.count())
        self.__widgets.insert(index, widget)
        if index <= self.__currentIndex or self.__currentIndex == -1:
            self.__currentIndex += 1
        return self.layout().insertWidget(index, widget)

    def removeWidget(self, widget):
        """
        Remove `widget` from the stack.

        .. note:: The widget is hidden but is not deleted.

        """
        index = self.__widgets.index(widget)
        self.layout().removeWidget(widget)
        self.__widgets.pop(index)

    def widget(self, index):
        """
        Return the widget at `index`
        """
        return self.__widgets[index]

    def indexOf(self, widget):
        """
        Return the index of `widget` in the stack.
        """
        return self.__widgets.index(widget)

    def count(self):
        """
        Return the number of widgets in the stack.
        """
        return max(self.layout().count() - 1, 0)

    def setCurrentWidget(self, widget):
        """
        Set the current shown widget.
        """
        index = self.__widgets.index(widget)
        self.setCurrentIndex(index)

    def setCurrentIndex(self, index):
        """
        Set the current shown widget index.
        """
        index = max(min(index, self.count() - 1), 0)
        if self.__currentIndex == -1:
            self.layout().setCurrentIndex(index)
            self.__currentIndex = index
            return

#        if not self.animationEnabled():
#            self.layout().setCurrentIndex(index)
#            self.__currentIndex = index
#            return

        # else start the animation
        current = self.__widgets[self.__currentIndex]
        next_widget = self.__widgets[index]

        def has_pending_resize(widget):
            return widget.testAttribute(Qt.WA_PendingResizeEvent) or \
                   not widget.testAttribute(Qt.WA_WState_Created)
        current_pix = next_pix = None
        if not has_pending_resize(current):
            current_pix = current.grab()
        if not has_pending_resize(next_widget):
            next_pix = next_widget.grab()

        with updates_disabled(self):
            self.__fadeWidget.setPixmap(current_pix)
            self.__fadeWidget.setPixmap2(next_pix)
            self.__nextCurrentIndex = index
            self.__transitionStart()

    def currentIndex(self):
        """
        Return the current shown widget index.
        """
        return self.__currentIndex

    def sizeHint(self):
        hint = QFrame.sizeHint(self)
        if hint.isEmpty():
            hint = QSize(0, 0)
        return hint

    def __transitionStart(self):
        """
        Start the transition.
        """
        log.debug("Stack transition start (%s)", str(self.objectName()))
        # Set the fade widget as the current widget
        self.__fadeWidget.blendingFactor_ = 0.0
        self.layout().setCurrentWidget(self.__fadeWidget)
        self.transitionAnimation.start()
        self.transitionStarted.emit()

    def __onTransitionFinished(self):
        """
        Transition has finished.
        """
        log.debug("Stack transition finished (%s)" % str(self.objectName()))
        self.__fadeWidget.blendingFactor_ = 1.0
        self.__currentIndex = self.__nextCurrentIndex
        with updates_disabled(self):
            self.layout().setCurrentIndex(self.__currentIndex)
        self.transitionFinished.emit()

    def __onLayoutCurrentChanged(self, index):
        # Suppress transitional __fadeWidget current widget
        if index != self.count():
            self.currentChanged.emit(index)
Example #6
0
class NodeBodyItem(GraphicsPathObject):
    """
    The central part (body) of the `NodeItem`.
    """
    def __init__(self, parent=None):
        GraphicsPathObject.__init__(self, parent)
        assert isinstance(parent, NodeItem)

        self.__processingState = 0
        self.__progress = -1
        self.__animationEnabled = False
        self.__isSelected = False
        self.__hasFocus = False
        self.__hover = False
        self.__shapeRect = QRectF(-10, -10, 20, 20)

        self.setAcceptHoverEvents(True)

        self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, True)
        self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True)

        self.setPen(QPen(Qt.NoPen))

        self.setPalette(default_palette())

        self.shadow = QGraphicsDropShadowEffect(
            blurRadius=3,
            color=QColor(SHADOW_COLOR),
            offset=QPointF(0, 0),
        )
        self.shadow.setEnabled(True)

        # An item with the same shape as this object, stacked behind this
        # item as a source for QGraphicsDropShadowEffect. Cannot attach
        # the effect to this item directly as QGraphicsEffect makes the item
        # non devicePixelRatio aware.
        shadowitem = GraphicsPathObject(self, objectName="shadow-shape-item")
        shadowitem.setPen(Qt.NoPen)
        shadowitem.setBrush(QBrush(QColor(SHADOW_COLOR).lighter()))
        shadowitem.setGraphicsEffect(self.shadow)
        shadowitem.setFlag(QGraphicsItem.ItemStacksBehindParent)
        self.__shadow = shadowitem
        self.__blurAnimation = QPropertyAnimation(self.shadow, b"blurRadius",
                                                  self)
        self.__blurAnimation.setDuration(100)
        self.__blurAnimation.finished.connect(self.__on_finished)

        self.__pingAnimation = QPropertyAnimation(self, b"scale", self)
        self.__pingAnimation.setDuration(250)
        self.__pingAnimation.setKeyValues([(0.0, 1.0), (0.5, 1.1), (1.0, 1.0)])

    # TODO: The body item should allow the setting of arbitrary painter
    # paths (for instance rounded rect, ...)
    def setShapeRect(self, rect):
        """
        Set the item's shape `rect`. The item should be confined within
        this rect.

        """
        path = QPainterPath()
        path.addEllipse(rect)
        self.setPath(path)
        self.__shadow.setPath(path)
        self.__shapeRect = rect

    def setPalette(self, palette):
        """
        Set the body color palette (:class:`QPalette`).
        """
        self.palette = palette
        self.__updateBrush()

    def setAnimationEnabled(self, enabled):
        """
        Set the node animation enabled.
        """
        if self.__animationEnabled != enabled:
            self.__animationEnabled = enabled

    def setProcessingState(self, state):
        """
        Set the processing state of the node.
        """
        if self.__processingState != state:
            self.__processingState = state
            if not state and self.__animationEnabled:
                self.ping()

    def setProgress(self, progress):
        """
        Set the progress indicator state of the node. `progress` should
        be a number between 0 and 100.

        """
        self.__progress = progress
        self.update()

    def ping(self):
        """
        Trigger a 'ping' animation.
        """
        animation_restart(self.__pingAnimation)

    def hoverEnterEvent(self, event):
        self.__hover = True
        self.__updateShadowState()
        return GraphicsPathObject.hoverEnterEvent(self, event)

    def hoverLeaveEvent(self, event):
        self.__hover = False
        self.__updateShadowState()
        return GraphicsPathObject.hoverLeaveEvent(self, event)

    def paint(self, painter, option, widget):
        """
        Paint the shape and a progress meter.
        """
        # Let the default implementation draw the shape
        if option.state & QStyle.State_Selected:
            # Prevent the default bounding rect selection indicator.
            option.state = option.state ^ QStyle.State_Selected
        GraphicsPathObject.paint(self, painter, option, widget)
        if self.__progress >= 0:
            # Draw the progress meter over the shape.
            # Set the clip to shape so the meter does not overflow the shape.
            painter.save()
            painter.setClipPath(self.shape(), Qt.ReplaceClip)
            color = self.palette.color(QPalette.ButtonText)
            pen = QPen(color, 5)
            painter.setPen(pen)
            painter.setRenderHints(QPainter.Antialiasing)
            span = max(1, int(self.__progress * 57.60))
            painter.drawArc(self.__shapeRect, 90 * 16, -span)
            painter.restore()

    def __updateShadowState(self):
        if self.__hasFocus:
            color = QColor(FOCUS_OUTLINE_COLOR)
            self.setPen(QPen(color, 1.5))
        else:
            self.setPen(QPen(Qt.NoPen))

        radius = 3
        enabled = False

        if self.__isSelected:
            enabled = True
            radius = 7

        if self.__hover:
            radius = 17
            enabled = True

        if enabled and not self.shadow.isEnabled():
            self.shadow.setEnabled(enabled)

        if self.__animationEnabled:
            if self.__blurAnimation.state() == QPropertyAnimation.Running:
                self.__blurAnimation.pause()

            self.__blurAnimation.setStartValue(self.shadow.blurRadius())
            self.__blurAnimation.setEndValue(radius)
            self.__blurAnimation.start()
        else:
            self.shadow.setBlurRadius(radius)

    def __updateBrush(self):
        palette = self.palette
        if self.__isSelected:
            cg = QPalette.Active
        else:
            cg = QPalette.Inactive

        palette.setCurrentColorGroup(cg)
        c1 = palette.color(QPalette.Light)
        c2 = palette.color(QPalette.Button)
        grad = radial_gradient(c2, c1)
        self.setBrush(QBrush(grad))

    # TODO: The selected and focus states should be set using the
    # QStyle flags (State_Selected. State_HasFocus)

    def setSelected(self, selected):
        """
        Set the `selected` state.

        .. note:: The item does not have `QGraphicsItem.ItemIsSelectable` flag.
                  This property is instead controlled by the parent NodeItem.

        """
        self.__isSelected = selected
        self.__updateBrush()

    def setHasFocus(self, focus):
        """
        Set the `has focus` state.

        .. note:: The item does not have `QGraphicsItem.ItemIsFocusable` flag.
                  This property is instead controlled by the parent NodeItem.

        """
        self.__hasFocus = focus
        self.__updateShadowState()

    def __on_finished(self):
        if self.shadow.blurRadius() == 0:
            self.shadow.setEnabled(False)
Example #7
0
class MessagesWidget(QWidget):
    """
    An iconified multiple message display area.

    `MessagesWidget` displays a short message along with an icon. If there
    are multiple messages they are summarized. The user can click on the
    widget to display the full message text in a popup view.
    """
    #: Signal emitted when an embedded html link is clicked
    #: (if `openExternalLinks` is `False`).
    linkActivated = Signal(str)

    #: Signal emitted when an embedded html link is hovered.
    linkHovered = Signal(str)

    Severity = Severity
    #: General informative message.
    Information = Severity.Information
    #: A warning message severity.
    Warning = Severity.Warning
    #: An error message severity.
    Error = Severity.Error

    Message = Message

    def __init__(self,
                 parent=None,
                 openExternalLinks=False,
                 defaultStyleSheet="",
                 **kwargs):
        kwargs.setdefault(
            "sizePolicy", QSizePolicy(QSizePolicy.Minimum,
                                      QSizePolicy.Minimum))
        super().__init__(parent, **kwargs)
        self.__openExternalLinks = openExternalLinks  # type: bool
        self.__messages = OrderedDict()  # type: Dict[Hashable, Message]
        #: The full (joined all messages text - rendered as html), displayed
        #: in a tooltip.
        self.__fulltext = ""
        #: The full text displayed in a popup. Is empty if the message is
        #: short
        self.__popuptext = ""
        #: Leading icon
        self.__iconwidget = IconWidget(
            sizePolicy=QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
        #: Inline  message text
        self.__textlabel = QLabel(
            wordWrap=False,
            textInteractionFlags=Qt.LinksAccessibleByMouse,
            openExternalLinks=self.__openExternalLinks,
            sizePolicy=QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum))
        #: Indicator that extended contents are accessible with a click on the
        #: widget.
        self.__popupicon = QLabel(
            sizePolicy=QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum),
            text="\N{VERTICAL ELLIPSIS}",
            visible=False,
        )
        self.__textlabel.linkActivated.connect(self.linkActivated)
        self.__textlabel.linkHovered.connect(self.linkHovered)
        self.setLayout(QHBoxLayout())
        self.layout().setContentsMargins(2, 1, 2, 1)
        self.layout().setSpacing(0)
        self.layout().addWidget(self.__iconwidget)
        self.layout().addSpacing(4)
        self.layout().addWidget(self.__textlabel)
        self.layout().addWidget(self.__popupicon)
        self.__textlabel.setAttribute(Qt.WA_MacSmallSize)
        self.__defaultStyleSheet = defaultStyleSheet

        self.anim = QPropertyAnimation(self.__iconwidget, b"opacity")
        self.anim.setDuration(350)
        self.anim.setStartValue(1)
        self.anim.setKeyValueAt(0.5, 0)
        self.anim.setEndValue(1)
        self.anim.setEasingCurve(QEasingCurve.OutQuad)
        self.anim.setLoopCount(5)

    def sizeHint(self):
        sh = super().sizeHint()
        h = self.style().pixelMetric(QStyle.PM_SmallIconSize)
        if all(m.isEmpty() for m in self.messages()):
            sh.setWidth(0)
        return sh.expandedTo(QSize(0, h + 2))

    def minimumSizeHint(self):
        msh = super().minimumSizeHint()
        h = self.style().pixelMetric(QStyle.PM_SmallIconSize)
        if all(m.isEmpty() for m in self.messages()):
            msh.setWidth(0)
        else:
            msh.setWidth(h + 2)
        return msh.expandedTo(QSize(0, h + 2))

    def setOpenExternalLinks(self, state):
        # type: (bool) -> None
        """
        If `True` then `linkActivated` signal will be emitted when the user
        clicks on an html link in a message, otherwise links are opened
        using `QDesktopServices.openUrl`
        """
        # TODO: update popup if open
        self.__openExternalLinks = state
        self.__textlabel.setOpenExternalLinks(state)

    def openExternalLinks(self):
        # type: () -> bool
        """
        """
        return self.__openExternalLinks

    def setDefaultStyleSheet(self, css):
        # type: (str) -> None
        """
        Set a default css to apply to the rendered text.

        Parameters
        ----------
        css : str
            A css style sheet as supported by Qt's Rich Text support.

        Note
        ----
        Not to be confused with `QWidget.styleSheet`

        See Also
        --------
        `Supported HTML Subset`_

        .. _`Supported HTML Subset`:
            http://doc.qt.io/qt-5/richtext-html-subset.html
        """
        if self.__defaultStyleSheet != css:
            self.__defaultStyleSheet = css
            self.__update()

    def defaultStyleSheet(self):
        """
        Returns
        -------
        css : str
            The current style sheet
        """
        return self.__defaultStyleSheet

    def setMessage(self, message_id, message):
        # type: (Hashable, Message) -> None
        """
        Add a `message` for `message_id` to the current display.

        Note
        ----
        Set an empty `Message` instance to clear the message display but
        retain the relative ordering in the display should a message for
        `message_id` reactivate.
        """
        self.__messages[message_id] = message
        self.__update()

    def removeMessage(self, message_id):
        # type: (Hashable) -> None
        """
        Remove message for `message_id` from the display.

        Note
        ----
        Setting an empty `Message` instance will also clear the display,
        however the relative ordering of the messages will be retained,
        should the `message_id` 'reactivate'.
        """
        del self.__messages[message_id]
        self.__update()

    def setMessages(self, messages):
        # type: (Union[Iterable[Tuple[Hashable, Message]], Dict[Hashable, Message]]) -> None
        """
        Set multiple messages in a single call.
        """
        messages = OrderedDict(messages)
        self.__messages.update(messages)
        self.__update()

    def clear(self):
        # type: () -> None
        """
        Clear all messages.
        """
        self.__messages.clear()
        self.__update()

    def messages(self):
        # type: () -> List[Message]
        """
        Return all set messages.

        Returns
        -------
        messages: `List[Message]`
        """
        return list(self.__messages.values())

    def summarize(self):
        # type: () -> Message
        """
        Summarize all the messages into a single message.
        """
        messages = [m for m in self.__messages.values() if not m.isEmpty()]
        if messages:
            return summarize(messages)
        else:
            return Message()

    def flashIcon(self):
        for message in self.messages():
            if message.severity != Severity.Information:
                self.anim.start(QPropertyAnimation.KeepWhenStopped)
                break

    @staticmethod
    def __styled(css, html):
        # Prepend css style sheet before a html fragment.
        if css.strip():
            return "<style>\n" + escape(css) + "\n</style>\n" + html
        else:
            return html

    def __update(self):
        """
        Update the current display state.
        """
        self.ensurePolished()
        summary = self.summarize()
        icon = message_icon(summary)
        self.__iconwidget.setIcon(icon)
        self.__iconwidget.setVisible(not (summary.isEmpty() or icon.isNull()))
        self.anim.start(QPropertyAnimation.KeepWhenStopped)
        self.__textlabel.setTextFormat(summary.textFormat)
        self.__textlabel.setText(summary.text)
        self.__textlabel.setVisible(bool(summary.text))
        messages = [m for m in self.__messages.values() if not m.isEmpty()]
        if messages:
            messages = sorted(messages,
                              key=attrgetter("severity"),
                              reverse=True)
            fulltext = "<hr/>".join(m.asHtml() for m in messages)
        else:
            fulltext = ""
        self.__fulltext = fulltext
        self.setToolTip(self.__styled(self.__defaultStyleSheet, fulltext))

        def is_short(m):
            return not (m.informativeText or m.detailedText)

        if not messages or len(messages) == 1 and is_short(messages[0]):
            self.__popuptext = ""
        else:
            self.__popuptext = fulltext
        self.__popupicon.setVisible(bool(self.__popuptext))
        self.layout().activate()

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            if self.__popuptext:
                popup = QMenu(self)
                label = QLabel(
                    self,
                    textInteractionFlags=Qt.TextBrowserInteraction,
                    openExternalLinks=self.__openExternalLinks,
                )
                label.setText(
                    self.__styled(self.__defaultStyleSheet, self.__popuptext))

                label.linkActivated.connect(self.linkActivated)
                label.linkHovered.connect(self.linkHovered)
                action = QWidgetAction(popup)
                action.setDefaultWidget(label)
                popup.addAction(action)
                popup.popup(event.globalPos(), action)
                event.accept()
            return
        else:
            super().mousePressEvent(event)

    def enterEvent(self, event):
        super().enterEvent(event)
        self.update()

    def leaveEvent(self, event):
        super().leaveEvent(event)
        self.update()

    def changeEvent(self, event):
        super().changeEvent(event)
        self.update()

    def paintEvent(self, event):
        opt = QStyleOption()
        opt.initFrom(self)
        if not self.__popupicon.isVisible():
            return

        if not (opt.state & QStyle.State_MouseOver
                or opt.state & QStyle.State_HasFocus):
            return

        palette = opt.palette  # type: QPalette
        if opt.state & QStyle.State_HasFocus:
            pen = QPen(palette.color(QPalette.Highlight))
        else:
            pen = QPen(palette.color(QPalette.Dark))

        if self.__fulltext and \
                opt.state & QStyle.State_MouseOver and \
                opt.state & QStyle.State_Active:
            g = QLinearGradient()
            g.setCoordinateMode(QLinearGradient.ObjectBoundingMode)
            base = palette.color(QPalette.Window)
            base.setAlpha(90)
            g.setColorAt(0, base.lighter(200))
            g.setColorAt(0.6, base)
            g.setColorAt(1.0, base.lighter(200))
            brush = QBrush(g)
        else:
            brush = QBrush(Qt.NoBrush)
        p = QPainter(self)
        p.setBrush(brush)
        p.setPen(pen)
        p.drawRect(opt.rect.adjusted(0, 0, -1, -1))
class SplitterResizer(QObject):
    """
    An object able to control the size of a widget in a QSplitter instance.
    """
    def __init__(self, parent=None):
        super().__init__(parent)
        self.__splitter = None
        self.__widget = None
        self.__updateOnShow = True  # Need __update on next show event
        self.__animationEnabled = True
        self.__size = -1
        self.__expanded = False
        self.__animation = QPropertyAnimation(
            self, b"size_", self, duration=200
        )
        self.__action = QAction("toogle-expanded", self, checkable=True)
        self.__action.triggered[bool].connect(self.setExpanded)

    def setSize(self, size):
        """Set the size of the controlled widget (either width or height
        depending on the orientation).

        .. note::
            The controlled widget's size is only updated when it it is shown.
        """
        if self.__size != size:
            self.__size = size
            self.__update()

    def size(self):
        """Return the size of the widget in the splitter (either height of
        width) depending on the splitter orientation.

        """
        if self.__splitter and self.__widget:
            index = self.__splitter.indexOf(self.__widget)
            sizes = self.__splitter.sizes()
            return sizes[index]
        else:
            return -1

    size_ = Property(int, fget=size, fset=setSize)

    def setAnimationEnabled(self, enable):
        """Enable/disable animation.
        """
        self.__animation.setDuration(0 if enable else 200)

    def animationEnabled(self):
        return self.__animation.duration() == 0

    def setSplitterAndWidget(self, splitter, widget):
        """Set the QSplitter and QWidget instance the resizer should control.

        .. note:: the widget must be in the splitter.

        """
        if splitter and widget and not splitter.indexOf(widget) > 0:
            raise ValueError("Widget must be in a spliter.")

        if self.__widget is not None:
            self.__widget.removeEventFilter(self)
        if self.__splitter is not None:
            self.__splitter.removeEventFilter(self)

        self.__splitter = splitter
        self.__widget = widget

        if widget is not None:
            widget.installEventFilter(self)
        if splitter is not None:
            splitter.installEventFilter(self)

        self.__update()

        size = self.size()
        if self.__expanded and size == 0:
            self.open()
        elif not self.__expanded and size > 0:
            self.close()

    def toogleExpandedAction(self):
        """Return a QAction that can be used to toggle expanded state.
        """
        return self.__action

    def open(self):
        """Open the controlled widget (expand it to sizeHint).
        """
        self.__expanded = True
        self.__action.setChecked(True)

        if self.__splitter is None or self.__widget is None:
            return

        hint = self.__widget.sizeHint()

        if self.__splitter.orientation() == Qt.Vertical:
            end = hint.height()
        else:
            end = hint.width()

        self.__animation.setStartValue(0)
        self.__animation.setEndValue(end)
        self.__animation.start()

    def close(self):
        """Close the controlled widget (shrink to size 0).
        """
        self.__expanded = False
        self.__action.setChecked(False)

        if self.__splitter is None or self.__widget is None:
            return

        self.__animation.setStartValue(self.size())
        self.__animation.setEndValue(0)
        self.__animation.start()

    def setExpanded(self, expanded):
        """Set the expanded state.

        """
        if self.__expanded != expanded:
            if expanded:
                self.open()
            else:
                self.close()

    def expanded(self):
        """Return the expanded state.
        """
        return self.__expanded

    def __update(self):
        """Update the splitter sizes.
        """
        if self.__splitter and self.__widget:
            if sum(self.__splitter.sizes()) == 0:
                # schedule update on next show event
                self.__updateOnShow = True
                return

            splitter = self.__splitter
            index = splitter.indexOf(self.__widget)
            sizes = splitter.sizes()
            current = sizes[index]
            diff = current - self.__size
            sizes[index] = self.__size
            sizes[index - 1] = sizes[index - 1] + diff
            self.__splitter.setSizes(sizes)

    def eventFilter(self, obj, event):
        if event.type() == QEvent.Resize and obj is self.__widget and \
                self.__animation.state() == QPropertyAnimation.Stopped:
            # Update the expanded state when the user opens/closes the widget
            # by dragging the splitter handle.
            if self.__splitter.orientation() == Qt.Vertical:
                size = event.size().height()
            else:
                size = event.size().width()

            if self.__expanded and size == 0:
                self.__action.setChecked(False)
                self.__expanded = False
            elif not self.__expanded and size > 0:
                self.__action.setChecked(True)
                self.__expanded = True

        if event.type() == QEvent.Show and obj is self.__splitter and \
                self.__updateOnShow:
            # Update the splitter state after receiving valid geometry
            self.__updateOnShow = False
            self.__update()
        return super().eventFilter(obj, event)
Example #9
0
class GraphicsIconItem(QGraphicsWidget):
    """
    A graphics item displaying an :class:`QIcon`.
    """
    def __init__(self, parent=None, icon=QIcon(), iconSize=QSize(), **kwargs):
        # type: (Optional[QGraphicsItem], QIcon, QSize, Any) -> None
        super().__init__(parent, **kwargs)
        self.setFlag(QGraphicsItem.ItemUsesExtendedStyleOption, True)

        if icon is None:
            icon = QIcon()

        if iconSize is None or iconSize.isNull():
            style = QApplication.instance().style()
            size = style.pixelMetric(style.PM_LargeIconSize)
            iconSize = QSize(size, size)

        self.__transformationMode = Qt.SmoothTransformation

        self.__iconSize = QSize(iconSize)
        self.__icon = QIcon(icon)

        self.anim = QPropertyAnimation(self, b"opacity")
        self.anim.setDuration(350)
        self.anim.setStartValue(1)
        self.anim.setKeyValueAt(0.5, 0)
        self.anim.setEndValue(1)
        self.anim.setEasingCurve(QEasingCurve.OutQuad)
        self.anim.setLoopCount(5)

    def setIcon(self, icon):
        # type: (QIcon) -> None
        """
        Set the icon (:class:`QIcon`).
        """
        if self.__icon != icon:
            self.__icon = QIcon(icon)
            self.update()

    def icon(self):
        # type: () -> QIcon
        """
        Return the icon (:class:`QIcon`).
        """
        return QIcon(self.__icon)

    def setIconSize(self, size):
        # type: (QSize) -> None
        """
        Set the icon (and this item's) size (:class:`QSize`).
        """
        if self.__iconSize != size:
            self.prepareGeometryChange()
            self.__iconSize = QSize(size)
            self.update()

    def iconSize(self):
        # type: () -> QSize
        """
        Return the icon size (:class:`QSize`).
        """
        return QSize(self.__iconSize)

    def setTransformationMode(self, mode):
        # type: (Qt.TransformationMode) -> None
        """
        Set pixmap transformation mode. (`Qt.SmoothTransformation` or
        `Qt.FastTransformation`).

        """
        if self.__transformationMode != mode:
            self.__transformationMode = mode
            self.update()

    def transformationMode(self):
        # type: () -> Qt.TransformationMode
        """
        Return the pixmap transformation mode.
        """
        return self.__transformationMode

    def boundingRect(self):
        # type: () -> QRectF
        return QRectF(0, 0, self.__iconSize.width(), self.__iconSize.height())

    def paint(self, painter, option, widget=None):
        # type: (QPainter, QStyleOptionGraphicsItem, Optional[QWidget]) -> None
        if not self.__icon.isNull():
            if option.state & QStyle.State_Selected:
                mode = QIcon.Selected
            elif option.state & QStyle.State_Enabled:
                mode = QIcon.Normal
            elif option.state & QStyle.State_Active:
                mode = QIcon.Active
            else:
                mode = QIcon.Disabled

            w, h = self.__iconSize.width(), self.__iconSize.height()
            target = QRect(0, 0, w, h)
            painter.setRenderHint(
                QPainter.SmoothPixmapTransform,
                self.__transformationMode == Qt.SmoothTransformation)
            self.__icon.paint(painter, target, Qt.AlignCenter, mode)
Example #10
0
class NodeAnchorItem(GraphicsPathObject):
    """
    The left/right widget input/output anchors.
    """
    def __init__(self, parent, **kwargs):
        # type: (Optional[QGraphicsItem], Any) -> None
        super().__init__(parent, **kwargs)
        self.__parentNodeItem = None  # type: Optional[NodeItem]
        self.setAcceptHoverEvents(True)
        self.setPen(QPen(Qt.NoPen))
        self.normalBrush = QBrush(QColor("#CDD5D9"))
        self.normalHoverBrush = QBrush(QColor("#9CACB4"))
        self.connectedBrush = self.normalHoverBrush
        self.connectedHoverBrush = QBrush(QColor("#959595"))
        self.setBrush(self.normalBrush)

        self.__animationEnabled = False
        self.__hover = False

        # Does this item have any anchored links.
        self.anchored = False

        if isinstance(parent, NodeItem):
            self.__parentNodeItem = parent
        else:
            self.__parentNodeItem = None

        self.__anchorPath = QPainterPath()
        self.__points = []  # type: List[AnchorPoint]
        self.__pointPositions = []  # type: List[float]

        self.__fullStroke = QPainterPath()
        self.__dottedStroke = QPainterPath()
        self.__shape = None  # type: Optional[QPainterPath]

        self.shadow = QGraphicsDropShadowEffect(
            blurRadius=0,
            color=QColor(SHADOW_COLOR),
            offset=QPointF(0, 0),
        )
        # self.setGraphicsEffect(self.shadow)
        self.shadow.setEnabled(False)

        shadowitem = GraphicsPathObject(self, objectName="shadow-shape-item")
        shadowitem.setPen(Qt.NoPen)
        shadowitem.setBrush(QBrush(QColor(SHADOW_COLOR)))
        shadowitem.setGraphicsEffect(self.shadow)
        shadowitem.setFlag(QGraphicsItem.ItemStacksBehindParent)
        self.__shadow = shadowitem
        self.__blurAnimation = QPropertyAnimation(self.shadow, b"blurRadius",
                                                  self)
        self.__blurAnimation.setDuration(50)
        self.__blurAnimation.finished.connect(self.__on_finished)

    def parentNodeItem(self):
        # type: () -> Optional['NodeItem']
        """
        Return a parent :class:`NodeItem` or ``None`` if this anchor's
        parent is not a :class:`NodeItem` instance.
        """
        return self.__parentNodeItem

    def setAnchorPath(self, path):
        # type: (QPainterPath) -> None
        """
        Set the anchor's curve path as a :class:`QPainterPath`.
        """
        self.__anchorPath = QPainterPath(path)
        # Create a stroke of the path.
        stroke_path = QPainterPathStroker()
        stroke_path.setCapStyle(Qt.RoundCap)

        # Shape is wider (bigger mouse hit area - should be settable)
        stroke_path.setWidth(25)
        self.prepareGeometryChange()
        self.__shape = stroke_path.createStroke(path)

        # The full stroke
        stroke_path.setWidth(3)
        self.__fullStroke = stroke_path.createStroke(path)

        # The dotted stroke (when not connected to anything)
        stroke_path.setDashPattern(Qt.DotLine)
        self.__dottedStroke = stroke_path.createStroke(path)

        if self.anchored:
            assert self.__fullStroke is not None
            self.setPath(self.__fullStroke)
            self.__shadow.setPath(self.__fullStroke)
            brush = self.connectedHoverBrush if self.__hover else self.connectedBrush
            self.setBrush(brush)
        else:
            assert self.__dottedStroke is not None
            self.setPath(self.__dottedStroke)
            self.__shadow.setPath(self.__dottedStroke)
            brush = self.normalHoverBrush if self.__hover else self.normalBrush
            self.setBrush(brush)

    def anchorPath(self):
        # type: () -> QPainterPath
        """
        Return the anchor path (:class:`QPainterPath`). This is a curve on
        which the anchor points lie.
        """
        return QPainterPath(self.__anchorPath)

    def setAnchored(self, anchored):
        # type: (bool) -> None
        """
        Set the items anchored state. When ``False`` the item draws it self
        with a dotted stroke.
        """
        self.anchored = anchored
        if anchored:
            self.setPath(self.__fullStroke)
            self.__shadow.setPath(self.__fullStroke)
            hover = self.__hover and len(
                self.__points) > 1  # a stylistic choice
            brush = self.connectedHoverBrush if hover else self.connectedBrush
            self.setBrush(brush)
        else:
            self.setPath(self.__dottedStroke)
            self.__shadow.setPath(self.__dottedStroke)
            brush = self.normalHoverBrush if self.__hover else self.normalBrush
            self.setBrush(brush)

    def setConnectionHint(self, hint=None):
        """
        Set the connection hint. This can be used to indicate if
        a connection can be made or not.

        """
        raise NotImplementedError

    def count(self):
        # type: () -> int
        """
        Return the number of anchor points.
        """
        return len(self.__points)

    def addAnchor(self, anchor, position=0.5):
        # type: (AnchorPoint, float) -> int
        """
        Add a new :class:`AnchorPoint` to this item and return it's index.

        The `position` specifies where along the `anchorPath` is the new
        point inserted.

        """
        return self.insertAnchor(self.count(), anchor, position)

    def insertAnchor(self, index, anchor, position=0.5):
        # type: (int, AnchorPoint, float) -> int
        """
        Insert a new :class:`AnchorPoint` at `index`.

        See also
        --------
        NodeAnchorItem.addAnchor

        """
        if anchor in self.__points:
            raise ValueError("%s already added." % anchor)

        self.__points.insert(index, anchor)
        self.__pointPositions.insert(index, position)

        anchor.setParentItem(self)
        anchor.setPos(self.__anchorPath.pointAtPercent(position))
        anchor.destroyed.connect(self.__onAnchorDestroyed)

        self.__updatePositions()

        self.setAnchored(bool(self.__points))

        hover = self.__hover and len(self.__points) > 1  # a stylistic choice
        anchor.setHoverState(hover)

        return index

    def removeAnchor(self, anchor):
        # type: (AnchorPoint) -> None
        """
        Remove and delete the anchor point.
        """
        anchor = self.takeAnchor(anchor)

        anchor.hide()
        anchor.setParentItem(None)
        anchor.deleteLater()

    def takeAnchor(self, anchor):
        # type: (AnchorPoint) -> AnchorPoint
        """
        Remove the anchor but don't delete it.
        """
        index = self.__points.index(anchor)

        del self.__points[index]
        del self.__pointPositions[index]

        anchor.destroyed.disconnect(self.__onAnchorDestroyed)

        self.__updatePositions()

        self.setAnchored(bool(self.__points))

        return anchor

    def __onAnchorDestroyed(self, anchor):
        # type: (QObject) -> None
        try:
            index = self.__points.index(anchor)
        except ValueError:
            return

        del self.__points[index]
        del self.__pointPositions[index]

    def anchorPoints(self):
        # type: () -> List[AnchorPoint]
        """
        Return a list of anchor points.
        """
        return list(self.__points)

    def anchorPoint(self, index):
        # type: (int) -> AnchorPoint
        """
        Return the anchor point at `index`.
        """
        return self.__points[index]

    def setAnchorPositions(self, positions):
        # type: (Iterable[float]) -> None
        """
        Set the anchor positions in percentages (0..1) along the path curve.
        """
        if self.__pointPositions != positions:
            self.__pointPositions = list(positions)

            self.__updatePositions()

    def anchorPositions(self):
        # type: () -> List[float]
        """
        Return the positions of anchor points as a list of floats where
        each float is between 0 and 1 and specifies where along the anchor
        path does the point lie (0 is at start 1 is at the end).
        """
        return list(self.__pointPositions)

    def shape(self):
        # type: () -> QPainterPath
        if self.__shape is not None:
            return QPainterPath(self.__shape)
        else:
            return super().shape()

    def boundingRect(self):
        if self.__shape is not None:
            return self.__shape.controlPointRect()
        else:
            return GraphicsPathObject.boundingRect(self)

    def hoverEnterEvent(self, event):
        self.__hover = True
        brush = self.connectedHoverBrush if self.anchored else self.normalHoverBrush
        self.setBrush(brush)
        self.__updateShadowState()
        return super().hoverEnterEvent(event)

    def hoverLeaveEvent(self, event):
        self.__hover = False
        brush = self.connectedBrush if self.anchored else self.normalBrush
        self.setBrush(brush)
        self.__updateShadowState()
        return super().hoverLeaveEvent(event)

    def setAnimationEnabled(self, enabled):
        # type: (bool) -> None
        """
        Set the anchor animation enabled.
        """
        if self.__animationEnabled != enabled:
            self.__animationEnabled = enabled

    def __updateShadowState(self):
        # type: () -> None
        radius = 5 if self.__hover else 0

        if radius != 0 and not self.shadow.isEnabled():
            self.shadow.setEnabled(True)

        if self.__animationEnabled:
            if self.__blurAnimation.state() == QPropertyAnimation.Running:
                self.__blurAnimation.pause()

            self.__blurAnimation.setStartValue(self.shadow.blurRadius())
            self.__blurAnimation.setEndValue(radius)
            self.__blurAnimation.start()
        else:
            self.shadow.setBlurRadius(radius)

        for anchor in self.anchorPoints():
            anchor.setHoverState(self.__hover)

    def __updatePositions(self):
        # type: () -> None
        """Update anchor points positions.
        """
        for point, t in zip(self.__points, self.__pointPositions):
            pos = self.__anchorPath.pointAtPercent(t)
            point.setPos(pos)

    def __on_finished(self):
        # type: () -> None
        if self.shadow.blurRadius() == 0:
            self.shadow.setEnabled(False)
Example #11
0
class LinkItem(QGraphicsWidget):
    """
    A Link item in the canvas that connects two :class:`.NodeItem`\\s in the
    canvas.

    The link curve connects two `Anchor` items (see :func:`setSourceItem`
    and :func:`setSinkItem`). Once the anchors are set the curve
    automatically adjusts its end points whenever the anchors move.

    An optional source/sink text item can be displayed above the curve's
    central point (:func:`setSourceName`, :func:`setSinkName`)

    """
    #: Signal emitted when the item has been activated (double-click)
    activated = Signal()

    #: Signal emitted the the item's selection state changes.
    selectedChanged = Signal(bool)

    #: Z value of the item
    Z_VALUE = 0

    #: Runtime link state value
    #: These are pulled from SchemeLink.State for ease of binding to it's
    #: state
    State = SchemeLink.State
    #: The link has no associated state.
    NoState = SchemeLink.NoState
    #: Link is empty; the source node does not have any value on output
    Empty = SchemeLink.Empty
    #: Link is active; the source node has a valid value on output
    Active = SchemeLink.Active
    #: The link is pending; the sink node is scheduled for update
    Pending = SchemeLink.Pending
    #: The link's input is marked as invalidated (not yet available).
    Invalidated = SchemeLink.Invalidated

    def __init__(self, parent=None, **kwargs):
        # type: (Optional[QGraphicsItem], Any) -> None
        self.__boundingRect = None  # type: Optional[QRectF]
        super().__init__(parent, **kwargs)
        self.setAcceptedMouseButtons(Qt.RightButton | Qt.LeftButton)
        self.setAcceptHoverEvents(True)
        self.__animationEnabled = False

        self.setZValue(self.Z_VALUE)

        self.sourceItem = None  # type: Optional[NodeItem]
        self.sourceAnchor = None  # type: Optional[AnchorPoint]
        self.sinkItem = None  # type: Optional[NodeItem]
        self.sinkAnchor = None  # type: Optional[AnchorPoint]

        self.curveItem = LinkCurveItem(self)

        self.linkTextItem = GraphicsTextItem(self)
        self.linkTextItem.setAcceptedMouseButtons(Qt.NoButton)
        self.linkTextItem.setAcceptHoverEvents(False)
        self.__sourceName = ""
        self.__sinkName = ""

        self.__dynamic = False
        self.__dynamicEnabled = False
        self.__state = LinkItem.NoState
        self.__channelNamesVisible = True
        self.hover = False

        self.channelNameAnim = QPropertyAnimation(self.linkTextItem,
                                                  b'opacity', self)
        self.channelNameAnim.setDuration(50)

        self.prepareGeometryChange()
        self.__updatePen()
        self.__updatePalette()
        self.__updateFont()

    def setSourceItem(self, item, signal=None, anchor=None):
        # type: (Optional[NodeItem], Optional[OutputSignal], Optional[AnchorPoint]) -> None
        """
        Set the source `item` (:class:`.NodeItem`). Use `anchor`
        (:class:`.AnchorPoint`) as the curve start point (if ``None`` a new
        output anchor will be created using ``item.newOutputAnchor()``).

        Setting item to ``None`` and a valid anchor is a valid operation
        (for instance while mouse dragging one end of the link).
        """
        if item is not None and anchor is not None:
            if anchor not in item.outputAnchors():
                raise ValueError("Anchor must be belong to the item")

        if self.sourceItem != item:
            if self.sourceAnchor:
                # Remove a previous source item and the corresponding anchor
                self.sourceAnchor.scenePositionChanged.disconnect(
                    self._sourcePosChanged)

                if self.sourceItem is not None:
                    self.sourceItem.removeOutputAnchor(self.sourceAnchor)
                    self.sourceItem.selectedChanged.disconnect(
                        self.__updateSelectedState)
                self.sourceItem = self.sourceAnchor = None

            self.sourceItem = item

            if item is not None and anchor is None:
                # Create a new output anchor for the item if none is provided.
                anchor = item.newOutputAnchor(signal)
            if item is not None:
                item.selectedChanged.connect(self.__updateSelectedState)

        if anchor != self.sourceAnchor:
            if self.sourceAnchor is not None:
                self.sourceAnchor.scenePositionChanged.disconnect(
                    self._sourcePosChanged)

            self.sourceAnchor = anchor

            if self.sourceAnchor is not None:
                self.sourceAnchor.scenePositionChanged.connect(
                    self._sourcePosChanged)

        self.__updateCurve()

    def setSinkItem(self, item, signal=None, anchor=None):
        # type: (Optional[NodeItem], Optional[InputSignal], Optional[AnchorPoint]) -> None
        """
        Set the sink `item` (:class:`.NodeItem`). Use `anchor`
        (:class:`.AnchorPoint`) as the curve end point (if ``None`` a new
        input anchor will be created using ``item.newInputAnchor()``).

        Setting item to ``None`` and a valid anchor is a valid operation
        (for instance while mouse dragging one and of the link).
        """
        if item is not None and anchor is not None:
            if anchor not in item.inputAnchors():
                raise ValueError("Anchor must be belong to the item")

        if self.sinkItem != item:
            if self.sinkAnchor:
                # Remove a previous source item and the corresponding anchor
                self.sinkAnchor.scenePositionChanged.disconnect(
                    self._sinkPosChanged)

                if self.sinkItem is not None:
                    self.sinkItem.removeInputAnchor(self.sinkAnchor)
                    self.sinkItem.selectedChanged.disconnect(
                        self.__updateSelectedState)
                self.sinkItem = self.sinkAnchor = None

            self.sinkItem = item

            if item is not None and anchor is None:
                # Create a new input anchor for the item if none is provided.
                anchor = item.newInputAnchor(signal)
            if item is not None:
                item.selectedChanged.connect(self.__updateSelectedState)

        if self.sinkAnchor != anchor:
            if self.sinkAnchor is not None:
                self.sinkAnchor.scenePositionChanged.disconnect(
                    self._sinkPosChanged)

            self.sinkAnchor = anchor

            if self.sinkAnchor is not None:
                self.sinkAnchor.scenePositionChanged.connect(
                    self._sinkPosChanged)

        self.__updateCurve()

    def setChannelNamesVisible(self, visible):
        # type: (bool) -> None
        """
        Set the visibility of the channel name text.
        """
        if self.__channelNamesVisible != visible:
            self.__channelNamesVisible = visible
        self.__initChannelNameOpacity()

    def setSourceName(self, name):
        # type: (str) -> None
        """
        Set the name of the source (used in channel name text).
        """
        if self.__sourceName != name:
            self.__sourceName = name
            self.__updateText()

    def sourceName(self):
        # type: () -> str
        """
        Return the source name.
        """
        return self.__sourceName

    def setSinkName(self, name):
        # type: (str) -> None
        """
        Set the name of the sink (used in channel name text).
        """
        if self.__sinkName != name:
            self.__sinkName = name
            self.__updateText()

    def sinkName(self):
        # type: () -> str
        """
        Return the sink name.
        """
        return self.__sinkName

    def setAnimationEnabled(self, enabled):
        # type: (bool) -> None
        """
        Set the link item animation enabled state.
        """
        if self.__animationEnabled != enabled:
            self.__animationEnabled = enabled
        self.curveItem.setAnimationEnabled(enabled)

    def _sinkPosChanged(self, *arg):
        self.__updateCurve()

    def _sourcePosChanged(self, *arg):
        self.__updateCurve()

    def __updateCurve(self):
        # type: () -> None
        self.prepareGeometryChange()
        self.__boundingRect = None
        if self.sourceAnchor and self.sinkAnchor:
            source_pos = self.sourceAnchor.anchorScenePos()
            sink_pos = self.sinkAnchor.anchorScenePos()
            source_pos = self.curveItem.mapFromScene(source_pos)
            sink_pos = self.curveItem.mapFromScene(sink_pos)

            # Adaptive offset for the curve control points to avoid a
            # cusp when the two points have the same y coordinate
            # and are close together
            delta = source_pos - sink_pos
            dist = math.sqrt(delta.x()**2 + delta.y()**2)
            cp_offset = min(dist / 2.0, 60.0)

            # TODO: make the curve tangent orthogonal to the anchors path.
            path = QPainterPath()
            path.moveTo(source_pos)
            path.cubicTo(source_pos + QPointF(cp_offset, 0),
                         sink_pos - QPointF(cp_offset, 0), sink_pos)

            self.curveItem.setCurvePath(path)
            self.__updateText()
        else:
            self.setHoverState(False)
            self.curveItem.setPath(QPainterPath())

    def __updateText(self):
        # type: () -> None
        self.prepareGeometryChange()
        self.__boundingRect = None

        if self.__sourceName or self.__sinkName:
            if self.__sourceName != self.__sinkName:
                text = ("<nobr>{0}</nobr> \u2192 <nobr>{1}</nobr>".format(
                    escape(self.__sourceName), escape(self.__sinkName)))
            else:
                # If the names are the same show only one.
                # Is this right? If the sink has two input channels of the
                # same type having the name on the link help elucidate
                # the scheme.
                text = escape(self.__sourceName)
        else:
            text = ""

        self.linkTextItem.setHtml(
            '<div align="center" style="font-size: small" >{0}</div>'.format(
                text))
        path = self.curveItem.curvePath()

        # Constrain the text width if it is too long to fit on a single line
        # between the two ends
        if not path.isEmpty():
            # Use the distance between the start/end points as a measure of
            # available space
            diff = path.pointAtPercent(0.0) - path.pointAtPercent(1.0)
            available_width = math.sqrt(diff.x()**2 + diff.y()**2)
            # Get the ideal text width if it was unconstrained
            doc = self.linkTextItem.document().clone(self)
            doc.setTextWidth(-1)
            idealwidth = doc.idealWidth()
            doc.deleteLater()

            # Constrain the text width but not below a certain min width
            minwidth = 100
            textwidth = max(minwidth, min(available_width, idealwidth))
            self.linkTextItem.setTextWidth(textwidth)
        else:
            # Reset the fixed width
            self.linkTextItem.setTextWidth(-1)

        if not path.isEmpty():
            center = path.pointAtPercent(0.5)
            angle = path.angleAtPercent(0.5)

            brect = self.linkTextItem.boundingRect()

            transform = QTransform()
            transform.translate(center.x(), center.y())

            # Rotate text to be on top of link
            if 90 <= angle < 270:
                transform.rotate(180 - angle)
            else:
                transform.rotate(-angle)

            # Center and move above the curve path.
            transform.translate(-brect.width() / 2, -brect.height())

            self.linkTextItem.setTransform(transform)

    def removeLink(self):
        # type: () -> None
        self.setSinkItem(None)
        self.setSourceItem(None)
        self.__updateCurve()

    def setHoverState(self, state):
        # type: (bool) -> None
        if self.hover != state:
            self.prepareGeometryChange()
            self.__boundingRect = None
            self.hover = state
            if self.sinkAnchor:
                self.sinkAnchor.setHoverState(state)
            if self.sourceAnchor:
                self.sourceAnchor.setHoverState(state)
            self.curveItem.setHoverState(state)
            self.__updatePen()
            self.__updateChannelNameVisibility()
            self.__updateZValue()

    def __updateZValue(self):
        text_ss = self.linkTextItem.styleState()
        if self.hover:
            text_ss |= QStyle.State_HasFocus
            z = 9999
            self.linkTextItem.setParentItem(None)
        else:
            text_ss &= ~QStyle.State_HasFocus
            z = self.Z_VALUE
            self.linkTextItem.setParentItem(self)
        self.linkTextItem.setZValue(z)
        self.linkTextItem.setStyleState(text_ss)

    def mouseDoubleClickEvent(self, event):
        # type: (QGraphicsSceneMouseEvent) -> None
        super().mouseDoubleClickEvent(event)
        QTimer.singleShot(0, self.activated.emit)

    def hoverEnterEvent(self, event):
        # type: (QGraphicsSceneHoverEvent) -> None
        # Hover enter event happens when the mouse enters any child object
        # but we only want to show the 'hovered' shadow when the mouse
        # is over the 'curveItem', so we install self as an event filter
        # on the LinkCurveItem and listen to its hover events.
        self.curveItem.installSceneEventFilter(self)
        return super().hoverEnterEvent(event)

    def hoverLeaveEvent(self, event):
        # type: (QGraphicsSceneHoverEvent) -> None
        # Remove the event filter to prevent unnecessary work in
        # scene event filter when not needed
        self.curveItem.removeSceneEventFilter(self)
        return super().hoverLeaveEvent(event)

    def __initChannelNameOpacity(self):
        if self.__channelNamesVisible:
            self.linkTextItem.setOpacity(1)
        else:
            self.linkTextItem.setOpacity(0)

    def __updateChannelNameVisibility(self):
        if self.__channelNamesVisible:
            return
        enabled = self.hover or self.isSelected() or self.__isSelectedImplicit(
        )
        targetOpacity = 1 if enabled else 0
        if not self.__animationEnabled:
            self.linkTextItem.setOpacity(targetOpacity)
        else:
            if self.channelNameAnim.state() == QPropertyAnimation.Running:
                self.channelNameAnim.stop()
            self.channelNameAnim.setStartValue(self.linkTextItem.opacity())
            self.channelNameAnim.setEndValue(targetOpacity)
            self.channelNameAnim.start()

    def changeEvent(self, event):
        # type: (QEvent) -> None
        if event.type() == QEvent.PaletteChange:
            self.__updatePalette()
        elif event.type() == QEvent.FontChange:
            self.__updateFont()
        super().changeEvent(event)

    def sceneEventFilter(self, obj, event):
        # type: (QGraphicsItem, QEvent) -> bool
        if obj is self.curveItem:
            if event.type() == QEvent.GraphicsSceneHoverEnter:
                self.setHoverState(True)
            elif event.type() == QEvent.GraphicsSceneHoverLeave:
                self.setHoverState(False)

        return super().sceneEventFilter(obj, event)

    def boundingRect(self):
        # type: () -> QRectF
        if self.__boundingRect is None:
            self.__boundingRect = self.childrenBoundingRect()
        return self.__boundingRect

    def shape(self):
        # type: () -> QPainterPath
        return self.curveItem.shape()

    def setEnabled(self, enabled):
        # type: (bool) -> None
        """
        Reimplemented from :class:`QGraphicWidget`

        Set link enabled state. When disabled the link is rendered with a
        dashed line.

        """
        # This getter/setter pair override a property from the base class.
        # They should be renamed to e.g. setLinkEnabled/linkEnabled
        self.curveItem.setLinkEnabled(enabled)

    def isEnabled(self):
        # type: () -> bool
        return self.curveItem.isLinkEnabled()

    def setDynamicEnabled(self, enabled):
        # type: (bool) -> None
        """
        Set the link's dynamic enabled state.

        If the link is `dynamic` it will be rendered in red/green color
        respectively depending on the state of the dynamic enabled state.

        """
        if self.__dynamicEnabled != enabled:
            self.__dynamicEnabled = enabled
            if self.__dynamic:
                self.__updatePen()

    def isDynamicEnabled(self):
        # type: () -> bool
        """
        Is the link dynamic enabled.
        """
        return self.__dynamicEnabled

    def setDynamic(self, dynamic):
        # type: (bool) -> None
        """
        Mark the link as dynamic (i.e. it responds to
        :func:`setDynamicEnabled`).

        """
        if self.__dynamic != dynamic:
            self.__dynamic = dynamic
            self.__updatePen()

    def isDynamic(self):
        # type: () -> bool
        """
        Is the link dynamic.
        """
        return self.__dynamic

    def setRuntimeState(self, state):
        # type: (_State) -> None
        """
        Style the link appropriate to the LinkItem.State

        Parameters
        ----------
        state : LinkItem.State
        """
        if self.__state != state:
            self.__state = state
            self.__updateAnchors()
            self.__updatePen()

    def runtimeState(self):
        # type: () -> _State
        return self.__state

    def __updatePen(self):
        # type: () -> None
        self.prepareGeometryChange()
        self.__boundingRect = None
        if self.__dynamic:
            if self.__dynamicEnabled:
                color = QColor(0, 150, 0, 150)
            else:
                color = QColor(150, 0, 0, 150)

            normal = QPen(QBrush(color), 2.0)
            hover = QPen(QBrush(color.darker(120)), 2.0)
        else:
            normal = QPen(QBrush(QColor("#9CACB4")), 2.0)
            hover = QPen(QBrush(QColor("#959595")), 2.0)

        if self.__state & LinkItem.Empty:
            pen_style = Qt.DashLine
        else:
            pen_style = Qt.SolidLine

        normal.setStyle(pen_style)
        hover.setStyle(pen_style)

        if self.hover or self.isSelected():
            pen = hover
        else:
            pen = normal

        self.curveItem.setPen(pen)

    def __updatePalette(self):
        # type: () -> None
        self.linkTextItem.setDefaultTextColor(self.palette().color(
            QPalette.Text))

    def __updateFont(self):
        # type: () -> None
        font = self.font()
        # linkTextItem will be rotated. Hinting causes bad positioning under
        # rotation so we prefer to disable it. This is only a hint, on windows
        # (DirectWrite engine) vertical hinting is still performed.
        font.setHintingPreference(QFont.PreferNoHinting)
        self.linkTextItem.setFont(font)

    def __updateAnchors(self):
        state = QStyle.State(0)
        if self.hover:
            state |= QStyle.State_MouseOver
        if self.isSelected() or self.__isSelectedImplicit():
            state |= QStyle.State_Selected
        if self.sinkAnchor is not None:
            self.sinkAnchor.indicator.setStyleState(state)
            self.sinkAnchor.indicator.setLinkState(self.__state)
        if self.sourceAnchor is not None:
            self.sourceAnchor.indicator.setStyleState(state)
            self.sourceAnchor.indicator.setLinkState(self.__state)

    def __updateSelectedState(self):
        selected = self.isSelected() or self.__isSelectedImplicit()
        self.linkTextItem.setSelectionState(selected)
        self.__updatePen()
        self.__updateAnchors()
        self.__updateChannelNameVisibility()
        self.curveItem.setSelectionState(selected)

    def __isSelectedImplicit(self):
        source, sink = self.sourceItem, self.sinkItem
        return (source is not None and source.isSelected() and sink is not None
                and sink.isSelected())

    def itemChange(self, change: QGraphicsItem.GraphicsItemChange,
                   value: Any) -> Any:
        if change == QGraphicsItem.ItemSelectedHasChanged:
            self.__updateSelectedState()
            self.selectedChanged.emit(value)
        return super().itemChange(change, value)
Example #12
0
class GraphicsIconItem(QGraphicsWidget):
    """
    A graphics item displaying an :class:`QIcon`.
    """
    def __init__(self, parent=None, icon=None, iconSize=None, **kwargs):
        QGraphicsItem.__init__(self, parent, **kwargs)
        self.setFlag(QGraphicsItem.ItemUsesExtendedStyleOption, True)

        if icon is None:
            icon = QIcon()

        if iconSize is None:
            style = QApplication.instance().style()
            size = style.pixelMetric(style.PM_LargeIconSize)
            iconSize = QSize(size, size)

        self.__transformationMode = Qt.SmoothTransformation

        self.__iconSize = QSize(iconSize)
        self.__icon = QIcon(icon)

        self._opacity = 1
        self.anim = QPropertyAnimation(self, b"opacity")
        self.anim.setDuration(350)
        self.anim.setStartValue(1)
        self.anim.setKeyValueAt(0.5, 0)
        self.anim.setEndValue(1)
        self.anim.setEasingCurve(QEasingCurve.OutQuad)
        self.anim.setLoopCount(5)

    def setIcon(self, icon):
        """
        Set the icon (:class:`QIcon`).
        """
        if self.__icon != icon:
            self.__icon = QIcon(icon)
            self.update()

    def getOpacity(self):
        return self._opacity

    def setOpacity(self, o):
        self._opacity = o
        self.update()

    opacity = Property(float, fget=getOpacity, fset=setOpacity)

    def icon(self):
        """
        Return the icon (:class:`QIcon`).
        """
        return QIcon(self.__icon)

    def setIconSize(self, size):
        """
        Set the icon (and this item's) size (:class:`QSize`).
        """
        if self.__iconSize != size:
            self.prepareGeometryChange()
            self.__iconSize = QSize(size)
            self.update()

    def iconSize(self):
        """
        Return the icon size (:class:`QSize`).
        """
        return QSize(self.__iconSize)

    def setTransformationMode(self, mode):
        """
        Set pixmap transformation mode. (`Qt.SmoothTransformation` or
        `Qt.FastTransformation`).

        """
        if self.__transformationMode != mode:
            self.__transformationMode = mode
            self.update()

    def transformationMode(self):
        """
        Return the pixmap transformation mode.
        """
        return self.__transformationMode

    def boundingRect(self):
        return QRectF(0, 0, self.__iconSize.width(), self.__iconSize.height())

    def paint(self, painter, option, widget=None):
        if not self.__icon.isNull():
            if option.state & QStyle.State_Selected:
                mode = QIcon.Selected
            elif option.state & QStyle.State_Enabled:
                mode = QIcon.Normal
            elif option.state & QStyle.State_Active:
                mode = QIcon.Active
            else:
                mode = QIcon.Disabled

            w, h = self.__iconSize.width(), self.__iconSize.height()
            target = QRect(0, 0, w, h)
            painter.setRenderHint(
                QPainter.SmoothPixmapTransform,
                self.__transformationMode == Qt.SmoothTransformation
            )
            painter.setOpacity(self._opacity)
            self.__icon.paint(painter, target, Qt.AlignCenter, mode)
Example #13
0
class MessagesWidget(QWidget):
    """
    An iconified multiple message display area.

    `MessagesWidget` displays a short message along with an icon. If there
    are multiple messages they are summarized. The user can click on the
    widget to display the full message text in a popup view.
    """
    #: Signal emitted when an embedded html link is clicked
    #: (if `openExternalLinks` is `False`).
    linkActivated = Signal(str)

    #: Signal emitted when an embedded html link is hovered.
    linkHovered = Signal(str)

    Severity = Severity
    #: General informative message.
    Information = Severity.Information
    #: A warning message severity.
    Warning = Severity.Warning
    #: An error message severity.
    Error = Severity.Error

    Message = Message

    def __init__(self, parent=None, openExternalLinks=False,
                 defaultStyleSheet="", **kwargs):
        kwargs.setdefault(
            "sizePolicy",
            QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
        )
        super().__init__(parent, **kwargs)
        self.__openExternalLinks = openExternalLinks  # type: bool
        self.__messages = OrderedDict()  # type: Dict[Hashable, Message]
        #: The full (joined all messages text - rendered as html), displayed
        #: in a tooltip.
        self.__fulltext = ""
        #: The full text displayed in a popup. Is empty if the message is
        #: short
        self.__popuptext = ""
        #: Leading icon
        self.__iconwidget = IconWidget(
            sizePolicy=QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
        )
        #: Inline  message text
        self.__textlabel = QLabel(
            wordWrap=False,
            textInteractionFlags=Qt.LinksAccessibleByMouse,
            openExternalLinks=self.__openExternalLinks,
            sizePolicy=QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
        )
        #: Indicator that extended contents are accessible with a click on the
        #: widget.
        self.__popupicon = QLabel(
            sizePolicy=QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum),
            text="\N{VERTICAL ELLIPSIS}",
            visible=False,
        )
        self.__textlabel.linkActivated.connect(self.linkActivated)
        self.__textlabel.linkHovered.connect(self.linkHovered)
        self.setLayout(QHBoxLayout())
        self.layout().setContentsMargins(2, 1, 2, 1)
        self.layout().setSpacing(0)
        self.layout().addWidget(self.__iconwidget)
        self.layout().addSpacing(4)
        self.layout().addWidget(self.__textlabel)
        self.layout().addWidget(self.__popupicon)
        self.__textlabel.setAttribute(Qt.WA_MacSmallSize)
        self.__defaultStyleSheet = defaultStyleSheet

        self.anim = QPropertyAnimation(self.__iconwidget, b"opacity")
        self.anim.setDuration(350)
        self.anim.setStartValue(1)
        self.anim.setKeyValueAt(0.5, 0)
        self.anim.setEndValue(1)
        self.anim.setEasingCurve(QEasingCurve.OutQuad)
        self.anim.setLoopCount(5)

    def sizeHint(self):
        sh = super().sizeHint()
        h = self.style().pixelMetric(QStyle.PM_SmallIconSize)
        if all(m.isEmpty() for m in self.messages()):
            sh.setWidth(0)
        return sh.expandedTo(QSize(0, h + 2))

    def minimumSizeHint(self):
        msh = super().minimumSizeHint()
        h = self.style().pixelMetric(QStyle.PM_SmallIconSize)
        if all(m.isEmpty() for m in self.messages()):
            msh.setWidth(0)
        else:
            msh.setWidth(h + 2)
        return msh.expandedTo(QSize(0, h + 2))

    def setOpenExternalLinks(self, state):
        # type: (bool) -> None
        """
        If `True` then `linkActivated` signal will be emitted when the user
        clicks on an html link in a message, otherwise links are opened
        using `QDesktopServices.openUrl`
        """
        # TODO: update popup if open
        self.__openExternalLinks = state
        self.__textlabel.setOpenExternalLinks(state)

    def openExternalLinks(self):
        # type: () -> bool
        """
        """
        return self.__openExternalLinks

    def setDefaultStyleSheet(self, css):
        # type: (str) -> None
        """
        Set a default css to apply to the rendered text.

        Parameters
        ----------
        css : str
            A css style sheet as supported by Qt's Rich Text support.

        Note
        ----
        Not to be confused with `QWidget.styleSheet`

        See Also
        --------
        `Supported HTML Subset`_

        .. _`Supported HTML Subset`:
            http://doc.qt.io/qt-5/richtext-html-subset.html
        """
        if self.__defaultStyleSheet != css:
            self.__defaultStyleSheet = css
            self.__update()

    def defaultStyleSheet(self):
        """
        Returns
        -------
        css : str
            The current style sheet
        """
        return self.__defaultStyleSheet

    def setMessage(self, message_id, message):
        # type: (Hashable, Message) -> None
        """
        Add a `message` for `message_id` to the current display.

        Note
        ----
        Set an empty `Message` instance to clear the message display but
        retain the relative ordering in the display should a message for
        `message_id` reactivate.
        """
        self.__messages[message_id] = message
        self.__update()

    def removeMessage(self, message_id):
        # type: (Hashable) -> None
        """
        Remove message for `message_id` from the display.

        Note
        ----
        Setting an empty `Message` instance will also clear the display,
        however the relative ordering of the messages will be retained,
        should the `message_id` 'reactivate'.
        """
        del self.__messages[message_id]
        self.__update()

    def setMessages(self, messages):
        # type: (Union[Iterable[Tuple[Hashable, Message]], Dict[Hashable, Message]]) -> None
        """
        Set multiple messages in a single call.
        """
        messages = OrderedDict(messages)
        self.__messages.update(messages)
        self.__update()

    def clear(self):
        # type: () -> None
        """
        Clear all messages.
        """
        self.__messages.clear()
        self.__update()

    def messages(self):
        # type: () -> List[Message]
        """
        Return all set messages.

        Returns
        -------
        messages: `List[Message]`
        """
        return list(self.__messages.values())

    def summarize(self):
        # type: () -> Message
        """
        Summarize all the messages into a single message.
        """
        messages = [m for m in self.__messages.values() if not m.isEmpty()]
        if messages:
            return summarize(messages)
        else:
            return Message()

    def flashIcon(self):
        for message in self.messages():
            if message.severity != Severity.Information:
                self.anim.start(QPropertyAnimation.KeepWhenStopped)
                break

    @staticmethod
    def __styled(css, html):
        # Prepend css style sheet before a html fragment.
        if css.strip():
            return "<style>\n" + escape(css) + "\n</style>\n" + html
        else:
            return html

    def __update(self):
        """
        Update the current display state.
        """
        self.ensurePolished()
        summary = self.summarize()
        icon = message_icon(summary)
        self.__iconwidget.setIcon(icon)
        self.__iconwidget.setVisible(not (summary.isEmpty() or icon.isNull()))
        self.anim.start(QPropertyAnimation.KeepWhenStopped)
        self.__textlabel.setTextFormat(summary.textFormat)
        self.__textlabel.setText(summary.text)
        self.__textlabel.setVisible(bool(summary.text))
        messages = [m for m in self.__messages.values() if not m.isEmpty()]
        if messages:
            messages = sorted(messages, key=attrgetter("severity"),
                              reverse=True)
            fulltext = "<hr/>".join(m.asHtml() for m in messages)
        else:
            fulltext = ""
        self.__fulltext = fulltext
        self.setToolTip(self.__styled(self.__defaultStyleSheet, fulltext))

        def is_short(m):
            return not (m.informativeText or m.detailedText)

        if not messages or len(messages) == 1 and is_short(messages[0]):
            self.__popuptext = ""
        else:
            self.__popuptext = fulltext
        self.__popupicon.setVisible(bool(self.__popuptext))
        self.layout().activate()

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            if self.__popuptext:
                popup = QMenu(self)
                label = QLabel(
                    self, textInteractionFlags=Qt.TextBrowserInteraction,
                    openExternalLinks=self.__openExternalLinks,
                )
                label.setText(self.__styled(self.__defaultStyleSheet,
                                            self.__popuptext))

                label.linkActivated.connect(self.linkActivated)
                label.linkHovered.connect(self.linkHovered)
                action = QWidgetAction(popup)
                action.setDefaultWidget(label)
                popup.addAction(action)
                popup.popup(event.globalPos(), action)
                event.accept()
            return
        else:
            super().mousePressEvent(event)

    def enterEvent(self, event):
        super().enterEvent(event)
        self.update()

    def leaveEvent(self, event):
        super().leaveEvent(event)
        self.update()

    def changeEvent(self, event):
        super().changeEvent(event)
        self.update()

    def paintEvent(self, event):
        opt = QStyleOption()
        opt.initFrom(self)
        if not self.__popupicon.isVisible():
            return

        if not (opt.state & QStyle.State_MouseOver or
                opt.state & QStyle.State_HasFocus):
            return

        palette = opt.palette  # type: QPalette
        if opt.state & QStyle.State_HasFocus:
            pen = QPen(palette.color(QPalette.Highlight))
        else:
            pen = QPen(palette.color(QPalette.Dark))

        if self.__fulltext and \
                opt.state & QStyle.State_MouseOver and \
                opt.state & QStyle.State_Active:
            g = QLinearGradient()
            g.setCoordinateMode(QLinearGradient.ObjectBoundingMode)
            base = palette.color(QPalette.Window)
            base.setAlpha(90)
            g.setColorAt(0, base.lighter(200))
            g.setColorAt(0.6, base)
            g.setColorAt(1.0, base.lighter(200))
            brush = QBrush(g)
        else:
            brush = QBrush(Qt.NoBrush)
        p = QPainter(self)
        p.setBrush(brush)
        p.setPen(pen)
        p.drawRect(opt.rect.adjusted(0, 0, -1, -1))