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