######################################################################################################################
# Copyright (C) 2017-2020 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 graph view's QGraphicsScene.
:authors: M. Marin (KTH), P. Savolainen (VTT)
:date: 4.4.2018
"""
from PySide2.QtCore import Qt, Signal, Slot
from PySide2.QtWidgets import (
QGraphicsItem,
QGraphicsTextItem,
QGraphicsSimpleTextItem,
QGraphicsRectItem,
QGraphicsEllipseItem,
QGraphicsPixmapItem,
QGraphicsLineItem,
QStyle,
QApplication,
)
from PySide2.QtGui import QPen, QBrush, QPainterPath, QFont, QTextCursor, QTransform, QPalette, QGuiApplication
[docs]class EntityItem(QGraphicsPixmapItem):
def __init__(self, graph_view_form, x, y, extent, entity_id=None, entity_class_id=None):
"""Initializes item
Args:
graph_view_form (GraphViewForm): 'owner'
x (float): x-coordinate of central point
y (float): y-coordinate of central point
extent (int): Preferred extent
entity_id (int, optional): The entity id in case of a non-wip item
entity_class_id (int, optional): The entity class id in case of a wip item
"""
if not entity_id and not entity_class_id:
raise ValueError("Can't create an RelationshipItem without relationship id nor relationship class id.")
super().__init__()
self._graph_view_form = graph_view_form
self.db_mngr = graph_view_form.db_mngr
self.db_map = graph_view_form.db_map
self.entity_id = entity_id
self._entity_class_id = entity_class_id
self._entity_name = f"<WIP {self.entity_class_name}>"
self.arc_items = list()
self._extent = extent
self.refresh_icon()
self.setPos(x, y)
rect = self.boundingRect()
self.setOffset(-rect.width() / 2, -rect.height() / 2)
self._press_pos = None
self._merge_target = None
self._moved_on_scene = False
self._view_transform = QTransform() # To determine collisions in the view
self._views_cursor = {}
self._bg = None
self._bg_brush = Qt.NoBrush
self._init_bg()
self._bg.setFlag(QGraphicsItem.ItemStacksBehindParent, enabled=True)
self.is_wip = None
self._question_item = None # In case this becomes a template
if not self.entity_id:
self.become_wip()
else:
self.become_whole()
self.setZValue(0)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=True)
self.setFlag(QGraphicsItem.ItemIsMovable, enabled=True)
self.setFlag(QGraphicsItem.ItemIgnoresTransformations, enabled=True)
self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, enabled=True)
self.setAcceptHoverEvents(True)
self.setCursor(Qt.ArrowCursor)
@property
[docs] def entity_type(self):
raise NotImplementedError()
@property
[docs] def entity_name(self):
return self.db_mngr.get_item(self.db_map, self.entity_type, self.entity_id).get("name", self._entity_name)
@property
[docs] def entity_class_type(self):
return {"relationship": "relationship class", "object": "object class"}[self.entity_type]
@property
[docs] def entity_class_id(self):
return self.db_mngr.get_item(self.db_map, self.entity_type, self.entity_id).get(
"class_id", self._entity_class_id
)
@property
[docs] def entity_class_name(self):
return self.db_mngr.get_item(self.db_map, self.entity_class_type, self.entity_class_id)["name"]
[docs] def boundingRect(self):
return super().boundingRect() | self.childrenBoundingRect()
[docs] def _init_bg(self):
self._bg = QGraphicsRectItem(self.boundingRect(), self)
self._bg.setPen(Qt.NoPen)
[docs] def refresh_icon(self):
"""Refreshes the icon."""
pixmap = self.db_mngr.entity_class_icon(self.db_map, self.entity_class_type, self.entity_class_id).pixmap(
self._extent
)
self.setPixmap(pixmap)
[docs] def shape(self):
"""Returns a shape containing the entire bounding rect, to work better with icon transparency."""
path = QPainterPath()
path.addRect(self.boundingRect())
return path
[docs] def paint(self, painter, option, widget=None):
"""Shows or hides the selection halo."""
if option.state & (QStyle.State_Selected):
self._paint_as_selected()
option.state &= ~QStyle.State_Selected
else:
self._paint_as_deselected()
super().paint(painter, option, widget)
[docs] def _paint_as_selected(self):
self._bg.setBrush(QGuiApplication.palette().highlight())
[docs] def _paint_as_deselected(self):
self._bg.setBrush(self._bg_brush)
[docs] def add_arc_item(self, arc_item):
"""Adds an item to the list of arcs.
Args:
arc_item (ArcItem)
"""
self.arc_items.append(arc_item)
[docs] def become_wip(self):
"""Turns this item into a work-in-progress."""
self.is_wip = True
font = QFont("", 0.6 * self._extent)
self._question_item = OutlinedTextItem("?", self, font)
# Position question item
rect = super().boundingRect()
question_rect = self._question_item.boundingRect()
x = rect.center().x() - question_rect.width() / 2
y = rect.center().y() - question_rect.height() / 2
self._question_item.setPos(x, y)
self.setToolTip(self._make_wip_tool_tip())
[docs] def become_whole(self):
"""Removes the wip status from this item."""
self.is_wip = False
if self._question_item:
self.scene().removeItem(self._question_item)
[docs] def adjust_to_zoom(self, transform):
"""Saves the view's transform to determine collisions later on.
Args:
transform (QTransform): The view's transformation matrix after the zoom.
"""
self._view_transform = transform
factor = transform.m11()
self.setFlag(QGraphicsItem.ItemIgnoresTransformations, enabled=factor > 1)
[docs] def device_rect(self):
"""Returns the item's rect in devices's coordinates.
Used to accurately determine collisions.
"""
return self.deviceTransform(self._view_transform).mapRect(super().boundingRect())
[docs] def _find_merge_target(self):
"""Returns a suitable merge target if any.
Returns:
spinetoolbox.widgets.graph_view_graphics_items.EntityItem, NoneType
"""
scene = self.scene()
if not scene:
return None
colliding = [
x
for x in scene.items()
if x.isVisible()
and isinstance(x, EntityItem)
and x is not self
and x.device_rect().intersects(self.device_rect())
]
return next(iter(colliding), None)
# pylint: disable=no-self-use
[docs] def _is_target_valid(self):
"""Whether or not the registered merge target is valid.
Returns:
bool
"""
return False
# pylint: disable=no-self-use
[docs] def merge_into_target(self, force=False):
"""Merges this item into the registered target if valid.
Returns:
bool: True if merged, False if not.
"""
return False
[docs] def mousePressEvent(self, event):
"""Saves original position for bouncing purposes.
Args:
event (QGraphicsSceneMouseEvent)
"""
super().mousePressEvent(event)
self._press_pos = self.pos()
self._merge_target = None
[docs] def mouseMoveEvent(self, event):
"""Moves the item and all connected arcs. Also checks for a merge target
and sets an appropriate mouse cursor.
Args:
event (QGraphicsSceneMouseEvent)
"""
if event.buttons() & Qt.LeftButton == 0:
super().mouseMoveEvent(event)
return
move_by = event.scenePos() - event.lastScenePos()
# Move selected items together
for item in self.scene().selectedItems():
if isinstance(item, (EntityItem)):
item.moveBy(move_by.x(), move_by.y())
item.move_arc_items(move_by)
self._merge_target = self._find_merge_target()
for view in self.scene().views():
self._views_cursor.setdefault(view, view.viewport().cursor())
if not self._merge_target:
try:
view.viewport().setCursor(self._views_cursor[view])
except KeyError:
pass
continue
if self._is_target_valid():
view.viewport().setCursor(Qt.DragCopyCursor)
else:
view.viewport().setCursor(Qt.ForbiddenCursor)
[docs] def move_arc_items(self, pos_diff):
"""Moves arc items.
Args:
pos_diff (QPoint)
"""
raise NotImplementedError()
[docs] def mouseReleaseEvent(self, event):
"""Merges the item into the registered target if any. Bounces it if not possible.
Shrinks the scene if needed.
Args:
event (QGraphicsSceneMouseEvent)
"""
super().mouseReleaseEvent(event)
if self._merge_target:
if self.merge_into_target():
return
self._bounce_back(self.pos())
if self._moved_on_scene:
self._moved_on_scene = False
scene = self.scene()
scene.shrink_if_needed()
scene.item_move_finished.emit(self)
[docs] def _bounce_back(self, current_pos):
"""Bounces the item back from given position to its original position.
Args:
current_pos (QPoint)
"""
if self._press_pos is None:
return
self.move_arc_items(self._press_pos - current_pos)
self.setPos(self._press_pos)
[docs] def itemChange(self, change, value):
"""
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:
the same value given as input
"""
if change == QGraphicsItem.ItemScenePositionHasChanged:
self._moved_on_scene = True
return value
[docs] def set_all_visible(self, on):
"""Sets visibility status for this item and all arc items.
Args:
on (bool)
"""
for item in self.arc_items:
item.setVisible(on)
self.setVisible(on)
[docs] def wipe_out(self):
"""Removes this item and all its arc items from the scene."""
self.scene().removeItem(self)
for arc_item in self.arc_items:
if arc_item.scene():
arc_item.scene().removeItem(arc_item)
other_item = arc_item.other_item(self)
if other_item.is_wip:
other_item.wipe_out()
[docs]class RelationshipItem(EntityItem):
"""Relationship item to use with GraphViewForm."""
@property
[docs] def entity_type(self):
return "relationship"
@property
[docs] def object_class_id_list(self):
return self.db_mngr.get_item(self.db_map, "relationship class", self.entity_class_id)["object_class_id_list"]
@property
[docs] def object_name_list(self):
return self.db_mngr.get_item(self.db_map, "relationship", self.entity_id).get(
"object_name_list", ",".join(["<unnamed>" for _ in range(len(self.object_class_id_list))])
)
@property
[docs] def object_id_list(self):
return self.db_mngr.get_item(self.db_map, "relationship", self.entity_id).get("object_id_list")
@property
[docs] def db_representation(self):
return dict(
class_id=self.entity_class_id,
id=self.entity_id,
object_id_list=self.object_id_list,
object_name_list=self.object_name_list,
)
[docs] def _init_bg(self):
extent = self._extent
self._bg = QGraphicsEllipseItem(-0.5 * extent, -0.5 * extent, extent, extent, self)
self._bg.setPen(Qt.NoPen)
bg_color = QGuiApplication.palette().color(QPalette.Normal, QPalette.Window)
bg_color.setAlphaF(0.8)
self._bg_brush = QBrush(bg_color)
[docs] def validate_member_objects(self):
"""Goes through connected object items and tries to complete the relationship."""
if not self.is_wip:
return True
object_id_list = [arc_item.obj_item.entity_id for arc_item in self.arc_items]
if None in object_id_list:
return True
object_name_list = [arc_item.obj_item.entity_name for arc_item in self.arc_items]
entity_id = self._graph_view_form.add_relationship(self._entity_class_id, object_id_list, object_name_list)
if not entity_id:
return False
self.entity_id = entity_id
self.become_whole()
return True
[docs] def move_arc_items(self, pos_diff):
"""Moves arc items.
Args:
pos_diff (QPoint)
"""
for item in self.arc_items:
item.move_rel_item_by(pos_diff)
)
[docs] def become_whole(self):
super().become_whole()
self.setToolTip(self.object_name_list)
for item in self.arc_items:
item.become_whole()
[docs]class ObjectItem(EntityItem):
def __init__(self, graph_view_form, x, y, extent, entity_id=None, entity_class_id=None):
"""Initializes the item.
Args:
graph_view_form (GraphViewForm): 'owner'
x (float): x-coordinate of central point
y (float): y-coordinate of central point
extent (int): preferred extent
entity_id (int, optional): object id, if not given the item becomes a template
entity_class_id (int, optional): object class id, for template items
Raises:
ValueError: in case object_id and object_class_id are both not provided
"""
super().__init__(graph_view_form, x, y, extent, entity_id, entity_class_id)
self.setFlag(QGraphicsItem.ItemIsFocusable, enabled=True)
self.label_item = EntityLabelItem(self)
self.label_item.entity_name_edited.connect(self.finish_name_editing)
self.setZValue(0.5)
self.refresh_name()
@property
[docs] def entity_type(self):
return "object"
@property
[docs] def db_representation(self):
return dict(class_id=self.entity_class_id, id=self.entity_id, name=self.entity_name)
[docs] def refresh_name(self):
"""Refreshes the name."""
self.label_item.setPlainText(self.entity_name)
[docs] def _paint_as_selected(self):
if not self.label_item.hasFocus():
super()._paint_as_selected()
else:
self._paint_as_deselected()
)
[docs] def become_whole(self):
super().become_whole()
self.refresh_description()
[docs] def refresh_description(self):
description = self.db_mngr.get_item(self.db_map, "object", self.entity_id).get("description")
self.setToolTip(f"<html>{description}</html>")
[docs] def edit_name(self):
"""Starts editing the object name."""
self.setSelected(True)
self.label_item.start_editing()
@Slot(str)
[docs] def finish_name_editing(self, text):
"""Runs when the user finishes editing the name.
Adds or updates the object in the database.
Args:
text (str): The new name.
"""
if text == self.entity_name:
return
if self.is_wip:
# Add
entity_id = self._graph_view_form.add_object(self._entity_class_id, text)
if not entity_id:
return
self.entity_id = entity_id
for arc_item in self.arc_items:
arc_item.rel_item.validate_member_objects()
self.become_whole()
else:
# Update
self._graph_view_form.update_object(self.entity_id, text)
self.refresh_name()
[docs] def move_arc_items(self, pos_diff):
"""Moves arc items.
Args:
pos_diff (QPoint)
"""
for item in self.arc_items:
item.move_obj_item_by(pos_diff)
[docs] def keyPressEvent(self, event):
"""Starts editing the name if F2 is pressed.
Args:
event (QKeyEvent)
"""
if event.key() == Qt.Key_F2:
self.edit_name()
event.accept()
else:
super().keyPressEvent(event)
[docs] def mouseDoubleClickEvent(self, event):
"""Starts editing the name.
Args:
event (QGraphicsSceneMouseEvent)
"""
self.edit_name()
event.accept()
[docs] def _is_in_wip_relationship(self):
return any(arc_item.rel_item.is_wip for arc_item in self.arc_items)
[docs] def _is_target_valid(self):
"""Whether or not the registered merge target is valid.
Returns:
bool
"""
return (
self._merge_target
and isinstance(self._merge_target, ObjectItem)
and (self._is_in_wip_relationship() or self._merge_target._is_in_wip_relationship())
and self._merge_target.entity_class_id == self.entity_class_id
)
[docs] def merge_into_target(self, force=False):
"""Merges this item into the registered target if valid.
Args:
force (bool)
Returns:
bool: True if merged, False if not.
"""
if not self._is_target_valid() and not force:
return False
if not self.is_wip and self._merge_target.is_wip:
# Make sure we don't merge a non-wip into a wip item
other = self._merge_target
other._merge_target = self
return other.merge_into_target(force=force)
for arc_item in self.arc_items:
arc_item.obj_item = self._merge_target
if not all(arc_item.rel_item.validate_member_objects() for arc_item in self.arc_items):
# Revert
for arc_item in self.arc_items:
arc_item.obj_item = self
return False
self._merge_target.arc_items.extend(self.arc_items)
self.move_arc_items(self._merge_target.pos() - self.pos())
self.scene().removeItem(self)
return True
[docs]class EntityLabelItem(QGraphicsTextItem):
"""Label item for items in GraphViewForm."""
[docs] entity_name_edited = Signal(str)
def __init__(self, entity_item):
"""Initializes item.
Args:
entity_item (spinetoolbox.widgets.graph_view_graphics_items.EntityItem): The parent item.
"""
super().__init__(entity_item)
self.entity_item = entity_item
self._font = QApplication.font()
self._font.setPointSize(11)
self.setFont(self._font)
self.bg = QGraphicsRectItem(self)
color = QGuiApplication.palette().color(QPalette.Normal, QPalette.ToolTipBase)
color.setAlphaF(0.8)
self.set_bg_color(color)
self.bg.setFlag(QGraphicsItem.ItemStacksBehindParent)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=False)
self.setAcceptHoverEvents(False)
self._cursor = self.textCursor()
self._text_backup = None
[docs] def setPlainText(self, text):
"""Set texts and resets position.
Args:
text (str)
"""
super().setPlainText(text)
self.reset_position()
[docs] def reset_position(self):
"""Adapts item geometry so text is always centered."""
rectf = self.boundingRect()
x = -rectf.width() / 2
y = rectf.height() + 4
self.setPos(x, y)
self.bg.setRect(self.boundingRect())
[docs] def set_bg_color(self, bg_color):
"""Sets background color.
Args:
bg_color (QColor)
"""
self.bg.setBrush(QBrush(bg_color))
[docs] def start_editing(self):
"""Starts editing."""
self.setTextInteractionFlags(Qt.TextEditorInteraction)
self.setFocus()
cursor = QTextCursor(self._cursor)
cursor.select(QTextCursor.Document)
self.setTextCursor(cursor)
self._text_backup = self.toPlainText()
[docs] def keyPressEvent(self, event):
"""Keeps text centered as the user types.
Gives up focus when the user presses Enter or Return.
Args:
event (QKeyEvent)
"""
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
self.clearFocus()
elif event.key() == Qt.Key_Escape:
self.setPlainText(self._text_backup)
self.clearFocus()
else:
super().keyPressEvent(event)
self.reset_position()
[docs] def focusOutEvent(self, event):
"""Ends editing and sends entity_name_edited signal."""
super().focusOutEvent(event)
self.setTextInteractionFlags(Qt.NoTextInteraction)
self.entity_name_edited.emit(self.toPlainText())
self.setTextCursor(self._cursor)
[docs] def mouseDoubleClickEvent(self, event):
"""Starts editing the name.
Args:
event (QGraphicsSceneMouseEvent)
"""
self.start_editing()
[docs]class ArcItem(QGraphicsLineItem):
"""Arc item to use with GraphViewForm. Connects a RelationshipItem to an ObjectItem."""
def __init__(self, rel_item, obj_item, width, is_wip=False):
"""Initializes item.
Args:
rel_item (spinetoolbox.widgets.graph_view_graphics_items.RelationshipItem): relationship item
obj_item (spinetoolbox.widgets.graph_view_graphics_items.ObjectItem): object item
width (float): Preferred line width
"""
super().__init__()
self.rel_item = rel_item
self.obj_item = obj_item
self._width = float(width)
self.is_wip = is_wip
src_x = rel_item.x()
src_y = rel_item.y()
dst_x = obj_item.x()
dst_y = obj_item.y()
self.setLine(src_x, src_y, dst_x, dst_y)
self._pen = QPen()
self._pen.setWidth(self._width)
color = QGuiApplication.palette().color(QPalette.Normal, QPalette.WindowText)
color.setAlphaF(0.8)
self._pen.setColor(color)
self._pen.setStyle(Qt.SolidLine)
self._pen.setCapStyle(Qt.RoundCap)
self.setPen(self._pen)
self.setZValue(-2)
rel_item.add_arc_item(self)
obj_item.add_arc_item(self)
if self.is_wip:
self.become_wip()
self.setCursor(Qt.ArrowCursor)
[docs] def mousePressEvent(self, event):
"""Accepts the event so it's not propagated."""
event.accept()
[docs] def other_item(self, item):
return {self.rel_item: self.obj_item, self.obj_item: self.rel_item}.get(item)
[docs] def become_wip(self):
"""Turns this arc into a work-in-progress."""
self.is_wip = True
self._pen.setStyle(Qt.DotLine)
self.setPen(self._pen)
[docs] def become_whole(self):
"""Removes the wip status from this arc."""
self.is_wip = False
self._pen.setStyle(Qt.SolidLine)
self.setPen(self._pen)
[docs] def move_rel_item_by(self, pos_diff):
"""Moves source point.
Args:
pos_diff (QPoint)
"""
line = self.line()
line.setP1(line.p1() + pos_diff)
self.setLine(line)
[docs] def move_obj_item_by(self, pos_diff):
"""Moves destination point.
Args:
pos_diff (QPoint)
"""
line = self.line()
line.setP2(line.p2() + pos_diff)
self.setLine(line)
[docs] def adjust_to_zoom(self, transform):
"""Adjusts the item's geometry so it stays the same size after performing a zoom.
Args:
transform (QTransform): The view's transformation matrix after the zoom.
"""
factor = transform.m11()
if factor < 1:
return
scaled_width = self._width / factor
self._pen.setWidthF(scaled_width)
self.setPen(self._pen)
[docs] def wipe_out(self):
self.obj_item.arc_items.remove(self)
self.rel_item.arc_items.remove(self)
[docs]class OutlinedTextItem(QGraphicsSimpleTextItem):
"""Outlined text item."""
def __init__(self, text, parent, font=QFont(), brush=QBrush(Qt.white), outline_pen=QPen(Qt.black, 3, Qt.SolidLine)):
"""Initializes item.
Args:
text (str): text to show
font (QFont, optional): font to display the text
brush (QBrush, optional)
outline_pen (QPen, optional)
"""
super().__init__(text, parent)
font.setWeight(QFont.Black)
self.setFont(font)
self.setBrush(brush)
self.setPen(outline_pen)