######################################################################################################################
# Copyright (C) 2017-2021 Spine project consortium
# This file is part of Spine Toolbox.
# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option)
# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General
# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
######################################################################################################################
"""
Classes for drawing graphics items on QGraphicsScene.
:authors: M. Marin (KTH), P. Savolainen (VTT)
:date: 4.4.2018
"""
from PySide2.QtCore import Qt, QPointF, QRectF, QParallelAnimationGroup
from PySide2.QtWidgets import (
QGraphicsItem,
QGraphicsTextItem,
QGraphicsSimpleTextItem,
QGraphicsRectItem,
QGraphicsEllipseItem,
QGraphicsColorizeEffect,
QGraphicsDropShadowEffect,
QApplication,
QToolTip,
)
from PySide2.QtGui import QColor, QPen, QBrush, QTextCursor, QPalette, QTextBlockFormat, QFont
from PySide2.QtSvg import QGraphicsSvgItem, QSvgRenderer
from spinetoolbox.project_commands import MoveIconCommand
from spine_engine.spine_engine import ItemExecutionFinishState
[docs]class ProjectItemIcon(QGraphicsRectItem):
"""Base class for project item icons drawn in Design View."""
def __init__(self, toolbox, icon_file, icon_color):
"""
Args:
toolbox (ToolboxUI): QMainWindow instance
icon_file (str): Path to icon resource
icon_color (QColor): Icon's color
"""
super().__init__()
self._toolbox = toolbox
self.icon_file = icon_file
self._moved_on_scene = False
self.previous_pos = QPointF()
self.current_pos = QPointF()
self.icon_group = {self}
self.renderer = QSvgRenderer()
self.svg_item = QGraphicsSvgItem(self)
self.colorizer = QGraphicsColorizeEffect()
self.setRect(QRectF(-self.ITEM_EXTENT / 2, -self.ITEM_EXTENT / 2, self.ITEM_EXTENT, self.ITEM_EXTENT))
self.text_font_size = 10 # point size
# Make item name graphics item.
self._name = ""
self.name_item = QGraphicsSimpleTextItem(self._name, self)
self.set_name_attributes() # Set font, size, position, etc.
# Make connector buttons
self.connectors = dict(
bottom=ConnectorButton(self, toolbox, position="bottom"),
left=ConnectorButton(self, toolbox, position="left"),
right=ConnectorButton(self, toolbox, position="right"),
)
# Make exclamation and rank icons
self.exclamation_icon = ExclamationIcon(self)
self.execution_icon = ExecutionIcon(self)
self.rank_icon = RankIcon(self)
h, s, _, a = icon_color.getHsl()
background_color = QColor.fromHsl(h, s, 240, a)
brush = QBrush(background_color)
self._setup(brush, icon_file, icon_color)
shadow_effect = QGraphicsDropShadowEffect()
shadow_effect.setOffset(1)
shadow_effect.setEnabled(False)
self.setGraphicsEffect(shadow_effect)
[docs] def finalize(self, name, x, y):
"""
Names the icon and moves it by given amount.
Args:
name (str): icon's name
x (int): horizontal offset
y (int): vertical offset
"""
self.update_name_item(name)
self.moveBy(x, y)
[docs] def _setup(self, brush, svg, svg_color):
"""Setup item's attributes.
Args:
brush (QBrush): Used in filling the background rectangle
svg (str): Path to SVG icon file
svg_color (QColor): Color of SVG icon
"""
self.setPen(QPen(Qt.black, 1, Qt.SolidLine))
self.setBrush(brush)
self.colorizer.setColor(svg_color)
# Load SVG
loading_ok = self.renderer.load(svg)
if not loading_ok:
self._toolbox.msg_error.emit("Loading SVG icon from resource:{0} failed".format(svg))
return
size = self.renderer.defaultSize()
self.svg_item.setSharedRenderer(self.renderer)
self.svg_item.setElementId("") # guess empty string loads the whole file
dim_max = max(size.width(), size.height())
rect_w = self.rect().width() # Parent rect width
margin = 32
self.svg_item.setScale((rect_w - margin) / dim_max)
self.svg_item.setPos(self.rect().center() - self.svg_item.sceneBoundingRect().center())
self.svg_item.setGraphicsEffect(self.colorizer)
self.setFlag(QGraphicsItem.ItemIsMovable, enabled=True)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=True)
self.setFlag(QGraphicsItem.ItemIsFocusable, enabled=True)
self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, enabled=True)
self.setAcceptHoverEvents(True)
self.setCursor(Qt.PointingHandCursor)
# Set exclamation, execution_log, and rank icons position
self.exclamation_icon.setPos(self.rect().topRight() - self.exclamation_icon.sceneBoundingRect().topRight())
self.execution_icon.setPos(
self.rect().bottomRight() - 0.5 * self.execution_icon.sceneBoundingRect().bottomRight()
)
self.rank_icon.setPos(self.rect().topLeft())
[docs] def name(self):
"""Returns name of the item that is represented by this icon.
Returns:
str: icon's name
"""
return self._name
[docs] def update_name_item(self, new_name):
"""Set a new text to name item.
Args:
new_name (str): icon's name
"""
self._name = new_name
self.name_item.setText(new_name)
self.set_name_attributes()
[docs] def set_name_attributes(self):
"""Set name QGraphicsSimpleTextItem attributes (font, size, position, etc.)"""
# Set font size and style
font = self.name_item.font()
font.setPointSize(self.text_font_size)
font.setBold(True)
self.name_item.setFont(font)
# Set name item position (centered on top of the master icon)
name_width = self.name_item.boundingRect().width()
name_height = self.name_item.boundingRect().height()
self.name_item.setPos(
self.rect().x() + self.rect().width() / 2 - name_width / 2, self.rect().y() - name_height - 4
)
[docs] def outgoing_links(self):
"""Collects outgoing links.
Returns:
list of LinkBase: outgoing links
"""
return [l for conn in self.connectors.values() for l in conn.outgoing_links()]
[docs] def incoming_links(self):
"""Collects incoming links.
Returns:
list of LinkBase: outgoing links
"""
return [l for conn in self.connectors.values() for l in conn.incoming_links()]
[docs] def run_execution_leave_animation(self, excluded):
"""
Starts the animation associated with execution leaving the icon.
Args:
excluded (bool): True if project item was not actually executed.
"""
animation_group = QParallelAnimationGroup(self._toolbox)
for link in self.outgoing_links():
animation_group.addAnimation(link.make_execution_animation(excluded))
animation_group.start()
[docs] def hoverEnterEvent(self, event):
"""Sets a drop shadow effect to icon when mouse enters its boundaries.
Args:
event (QGraphicsSceneMouseEvent): Event
"""
self.prepareGeometryChange()
self.graphicsEffect().setEnabled(True)
event.accept()
[docs] def hoverLeaveEvent(self, event):
"""Disables the drop shadow when mouse leaves icon boundaries.
Args:
event (QGraphicsSceneMouseEvent): Event
"""
self.prepareGeometryChange()
self.graphicsEffect().setEnabled(False)
event.accept()
[docs] def mousePressEvent(self, event):
super().mousePressEvent(event)
self.icon_group = set(x for x in self.scene().selectedItems() if isinstance(x, ProjectItemIcon)) | {self}
for icon in self.icon_group:
icon.previous_pos = icon.scenePos()
[docs] def mouseMoveEvent(self, event):
"""Moves icon(s) while the mouse button is pressed.
Update links that are connected to selected icons.
Args:
event (QGraphicsSceneMouseEvent): Event
"""
super().mouseMoveEvent(event)
self.update_links_geometry()
[docs] def moveBy(self, dx, dy):
super().moveBy(dx, dy)
self.update_links_geometry()
[docs] def update_links_geometry(self):
"""Updates geometry of connected links to reflect this item's most recent position."""
links = set(link for icon in self.icon_group for conn in icon.connectors.values() for link in conn.links)
for link in links:
link.update_geometry()
[docs] def mouseReleaseEvent(self, event):
for icon in self.icon_group:
icon.current_pos = icon.scenePos()
# pylint: disable=undefined-variable
if (self.current_pos - self.previous_pos).manhattanLength() > qApp.startDragDistance():
self._toolbox.undo_stack.push(MoveIconCommand(self, self._toolbox.project()))
event.ignore()
super().mouseReleaseEvent(event)
[docs] def notify_item_move(self):
if self._moved_on_scene:
self._moved_on_scene = False
scene = self.scene()
scene.item_move_finished.emit(self)
[docs] def itemChange(self, change, value):
"""
Reacts to item removal and position changes.
In particular, destroys the drop shadow effect when the items is removed from a scene
and keeps track of item's movements on the scene.
Args:
change (GraphicsItemChange): a flag signalling the type of the change
value: a value related to the change
Returns:
Whatever super() does with the value parameter
"""
if change == QGraphicsItem.ItemScenePositionHasChanged:
self._moved_on_scene = True
elif change == QGraphicsItem.GraphicsItemChange.ItemSceneChange and value is None:
self.prepareGeometryChange()
self.setGraphicsEffect(None)
return super().itemChange(change, value)
[docs] def select_item(self):
"""Update GUI to show the details of the selected item."""
ind = self._toolbox.project_item_model.find_item(self.name())
self._toolbox.ui.treeView_project.setCurrentIndex(ind)
[docs]class ExecutionIcon(QGraphicsEllipseItem):
"""An icon to show information about the item's execution."""
[docs] _CHECK = "\uf00c" # Success
[docs] _CROSS = "\uf00d" # Fail
[docs] _CLOCK = "\uf017" # Waiting
[docs] _SKIP = "\uf054" # Excluded
def __init__(self, parent):
"""
Args:
parent (ProjectItemIcon): the parent item
"""
super().__init__(parent)
self._parent = parent
self._execution_state = "not started"
self._text_item = QGraphicsTextItem(self)
font = QFont('Font Awesome 5 Free Solid')
self._text_item.setFont(font)
parent_rect = parent.rect()
self.setRect(0, 0, 0.5 * parent_rect.width(), 0.5 * parent_rect.height())
self.setPen(Qt.NoPen)
# pylint: disable=undefined-variable
self.normal_brush = qApp.palette().window()
self.selected_brush = qApp.palette().highlight()
self.setBrush(self.normal_brush)
self.setAcceptHoverEvents(True)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=False)
self.hide()
[docs] def item_name(self):
return self._parent.name()
[docs] def _repaint(self, text, color):
self._text_item.prepareGeometryChange()
self._text_item.setPos(0, 0)
self._text_item.setPlainText(text)
self._text_item.setDefaultTextColor(color)
size = self._text_item.boundingRect().size()
dim_max = max(size.width(), size.height())
rect_w = self.rect().width()
self._text_item.setScale(rect_w / dim_max)
self._text_item.setPos(self.sceneBoundingRect().center() - self._text_item.sceneBoundingRect().center())
self.show()
[docs] def mark_execution_waiting(self):
self._execution_state = "waiting for dependencies"
self._repaint(self._CLOCK, QColor("orange"))
[docs] def mark_execution_started(self):
self._execution_state = "in progress"
self._repaint(self._CHECK, QColor("orange"))
[docs] def mark_execution_finished(self, item_finish_state):
if item_finish_state == ItemExecutionFinishState.SUCCESS:
self._execution_state = "completed"
self._repaint(self._CHECK, QColor("green"))
elif item_finish_state == ItemExecutionFinishState.EXCLUDED:
self._execution_state = "excluded"
self._repaint(self._CHECK, QColor("orange"))
elif item_finish_state == ItemExecutionFinishState.SKIPPED:
self._execution_state = "skipped"
self._repaint(self._SKIP, QColor("chocolate"))
else:
self._execution_state = "failed"
self._repaint(self._CROSS, QColor("red"))
[docs] def hoverEnterEvent(self, event):
tip = f"<p><b>Execution {self._execution_state}</b>. Select this item to see Console and Log messages.</p>"
QToolTip.showText(event.screenPos(), tip)
[docs] def hoverLeaveEvent(self, event):
QToolTip.hideText()
[docs]class ExclamationIcon(QGraphicsTextItem):
"""An icon to notify that a ProjectItem is missing some configuration."""
def __init__(self, parent):
"""
Args:
parent (ProjectItemIcon): the parent item
"""
super().__init__(parent)
self._parent = parent
self._notifications = list()
font = QFont('Font Awesome 5 Free Solid')
self.setFont(font)
self.setDefaultTextColor(QColor("red"))
self.setPlainText("\uf06a")
doc = self.document()
doc.setDocumentMargin(0)
self.setAcceptHoverEvents(True)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=False)
self.hide()
[docs] def clear_notifications(self):
"""Clear all notifications."""
self._notifications.clear()
self.hide()
[docs] def add_notification(self, text):
"""Add a notification."""
self._notifications.append(text)
self.show()
[docs] def remove_notification(self, subtext):
"""Remove the first notification that includes given subtext."""
k = next((i for i, text in enumerate(self._notifications) if subtext in text), None)
if k is not None:
self._notifications.pop(k)
if not self._notifications:
self.hide()
[docs] def hoverEnterEvent(self, event):
"""Shows notifications as tool tip.
Args:
event (QGraphicsSceneMouseEvent): Event
"""
if not self._notifications:
return
tip = "<p>" + "<p>".join(self._notifications)
QToolTip.showText(event.screenPos(), tip)
[docs] def hoverLeaveEvent(self, event):
"""Hides tool tip.
Args:
event (QGraphicsSceneMouseEvent): Event
"""
QToolTip.hideText()
[docs]class RankIcon(QGraphicsTextItem):
"""An icon to show the rank of a ProjectItem within its DAG."""
def __init__(self, parent):
"""
Args:
parent (ProjectItemIcon): the parent item
"""
super().__init__(parent)
self._parent = parent
rect_w = parent.rect().width() # Parent rect width
self.text_margin = 0.05 * rect_w
self.bg = QGraphicsRectItem(self.boundingRect(), self)
bg_brush = QApplication.palette().brush(QPalette.ToolTipBase)
self.bg.setBrush(bg_brush)
self.bg.setFlag(QGraphicsItem.ItemStacksBehindParent)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=False)
font = self.font()
font.setPointSize(parent.text_font_size)
font.setBold(True)
self.setFont(font)
doc = self.document()
doc.setDocumentMargin(0)
[docs] def set_rank(self, rank):
self.setPlainText(str(rank))
self.adjustSize()
self.setTextWidth(self.text_margin + self.textWidth())
self.bg.setRect(self.boundingRect())
# Align center
fmt = QTextBlockFormat()
fmt.setAlignment(Qt.AlignHCenter)
cursor = self.textCursor()
cursor.select(QTextCursor.Document)
cursor.mergeBlockFormat(fmt)
cursor.clearSelection()
self.setTextCursor(cursor)