######################################################################################################################
# Copyright (C) 2017-2022 Spine project consortium
# Copyright Spine Toolbox contributors
# 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."""
from enum import Enum, auto
from PySide6.QtCore import Qt, Signal, Slot, QLineF, QRectF, QPointF, QObject, QByteArray
from PySide6.QtSvgWidgets import QGraphicsSvgItem
from PySide6.QtWidgets import (
QGraphicsItem,
QGraphicsTextItem,
QGraphicsRectItem,
QGraphicsEllipseItem,
QGraphicsPathItem,
QStyle,
QApplication,
QMenu,
)
from PySide6.QtSvg import QSvgRenderer
from PySide6.QtGui import QPen, QBrush, QPainterPath, QPalette, QGuiApplication, QAction, QColor
from spinetoolbox.helpers import DB_ITEM_SEPARATOR, color_from_index
from spinetoolbox.widgets.custom_qwidgets import TitleWidgetAction
[docs]class EntityItem(QGraphicsRectItem):
def __init__(self, spine_db_editor, x, y, extent, db_map_ids, offset=None):
"""
Args:
spine_db_editor (SpineDBEditor): 'owner'
x (float): x-coordinate of central point
y (float): y-coordinate of central point
extent (int): Preferred extent
db_map_ids (tuple): tuple of (db_map, id) tuples
"""
super().__init__()
self._spine_db_editor = spine_db_editor
self.db_mngr = spine_db_editor.db_mngr
self._given_extent = extent
self._db_map_ids = db_map_ids
self._offset = offset
self._dx = self._dy = 0
self._removed_db_map_ids = ()
self.arc_items = []
self._circle_item = QGraphicsEllipseItem(self)
self._circle_item.setPen(Qt.NoPen)
self.set_pos(x, y)
self.setPen(Qt.NoPen)
self._svg_item = QGraphicsSvgItem(self)
self._svg_item.setZValue(100)
self._svg_item.setCacheMode(QGraphicsItem.CacheMode.NoCache) # Needed for the exported pdf to be vector
self._renderer = None
self._moved_on_scene = False
self._bg = None
self._bg_brush = None
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)
self.setToolTip(self._make_tool_tip())
self._highlight_color = Qt.transparent
self._db_map_entity_class_lists = {}
self.label_item = EntityLabelItem(self)
self.label_item.setVisible(not self.has_dimensions)
self.setZValue(0.5 if not self.has_dimensions else 0.25)
self._extent = None
self.set_up()
[docs] def clone(self):
return type(self)(
self._spine_db_editor,
self.pos().x(),
self.pos().y(),
self._given_extent,
self._db_map_ids,
offset=self._offset,
)
@property
[docs] def has_dimensions(self):
return bool(self.element_id_list(self.first_db_map))
@property
[docs] def db_map_ids(self):
return tuple(x for x in self._db_map_ids if x not in self._removed_db_map_ids)
@property
[docs] def original_db_map_ids(self):
return self._db_map_ids
@property
[docs] def name(self):
return self.db_mngr.get_item(self.first_db_map, "entity", self.first_id).get("name", "")
@property
[docs] def first_entity_class_id(self):
return self.db_mngr.get_item(self.first_db_map, "entity", self.first_id).get("class_id")
@property
[docs] def entity_class_name(self):
return self.db_mngr.get_item(self.first_db_map, "entity_class", self.first_entity_class_id).get("name", "")
@property
[docs] def dimension_id_list(self):
# FIXME: is this used?
return self.db_mngr.get_item(self.first_db_map, "entity_class", self.first_entity_class_id).get(
"dimension_id_list", ()
)
@property
[docs] def dimension_name_list(self):
return self.db_mngr.get_item(self.first_db_map, "entity_class", self.first_entity_class_id).get(
"dimension_name_list", ()
)
@property
[docs] def byname(self):
return self.db_mngr.get_item(self.first_db_map, "entity", self.first_id).get("entity_byname", ())
@property
[docs] def element_name_list(self):
return self.db_mngr.get_item(self.first_db_map, "entity", self.first_id).get("element_name_list", ())
[docs] def element_id_list(self, db_map):
return self.db_mngr.get_item(db_map, "entity", self.entity_id(db_map)).get("element_id_list", ())
@property
[docs] def element_byname_list(self):
# NOTE: Needed by EditEntitiesDialog
return self.db_mngr.get_item(self.first_db_map, "entity", self.first_id).get("element_byname_list", ())
@property
[docs] def first_db_map_id(self):
return next(iter(self.db_map_ids), (None, None))
@property
[docs] def first_id(self):
return self.first_db_map_id[1]
@property
[docs] def first_db_map(self):
return self.first_db_map_id[0]
@property
[docs] def display_data(self):
return self.name
@property
[docs] def display_database(self):
return ",".join([db_map.codename for db_map in self.db_maps])
@property
[docs] def db_maps(self):
return list(db_map for db_map, _id in self.db_map_ids)
[docs] def entity_class_id(self, db_map):
return self.db_mngr.get_item(db_map, "entity", self.entity_id(db_map)).get("class_id")
[docs] def entity_class_ids(self, db_map):
return {self.entity_class_id(db_map)} | {
x["superclass_id"]
for x in db_map.get_items("superclass_subclass", subclass_id=self.entity_class_id(db_map))
}
[docs] def entity_id(self, db_map):
return dict(self.db_map_ids).get(db_map)
[docs] def db_map_data(self, db_map):
# NOTE: Needed by EditEntitiesDialog
return self.db_mngr.get_item(db_map, "entity", self.entity_id(db_map))
[docs] def db_map_id(self, db_map):
# NOTE: Needed by EditEntitiesDialog
return self.entity_id(db_map)
[docs] def db_items(self, db_map):
for db_map_, id_ in self.db_map_ids:
if db_map_ == db_map:
yield dict(class_id=self.entity_class_id(db_map), id=id_)
[docs] def boundingRect(self):
return super().boundingRect() | self.childrenBoundingRect()
[docs] def set_pos(self, x, y):
x, y = self._snap(x, y)
self.setPos(x, y)
self.update_arcs_line()
[docs] def move_by(self, dx, dy):
self._dx += dx
self._dy += dy
dx, dy = self._snap(self._dx, self._dy)
if dx == dy == 0:
return
self.moveBy(dx, dy)
self._dx -= dx
self._dy -= dy
self.update_arcs_line()
ent_items = {arc_item.ent_item for arc_item in self.arc_items}
for ent_item in ent_items:
ent_item.update_entity_pos()
[docs] def _snap(self, x, y):
if self._spine_db_editor.qsettings.value("appSettings/snapEntities", defaultValue="false") != "true":
return (x, y)
grid_size = self._given_extent
x = round(x / grid_size) * grid_size
y = round(y / grid_size) * grid_size
return (x, y)
[docs] def has_unique_key(self):
"""Returns whether or not the item still has a single key in all the databases it represents.
Returns:
bool
"""
db_map_ids_by_key = {}
for db_map_id in self.db_map_ids:
key = self._spine_db_editor.get_entity_key(db_map_id)
db_map_ids_by_key.setdefault(key, []).append(db_map_id)
if len(db_map_ids_by_key) == 1:
return True
first_key = next(iter(db_map_ids_by_key))
self._db_map_ids = tuple(db_map_ids_by_key[first_key])
return False
[docs] def _get_name(self):
for db_map, id_ in self.db_map_ids:
name = self._spine_db_editor.get_item_name(db_map, id_)
if isinstance(name, str):
return name
[docs] def _get_prop(self, getter, index):
values = {getter(db_map, id_, index) for db_map, id_ in self.db_map_ids}
values.discard(None)
if not values:
return None
values.discard(self._spine_db_editor.NOT_SPECIFIED)
if not values:
return self._spine_db_editor.NOT_SPECIFIED
return next(iter(values))
[docs] def _get_color(self, index=None):
color = self._get_prop(self._spine_db_editor.get_item_color, index)
if color in (None, self._spine_db_editor.NOT_SPECIFIED):
return color
min_val, val, max_val = color
count = max(1, max_val - min_val)
k = val - min_val
return color_from_index(k, count)
[docs] def _get_arc_width(self, index=None):
arc_width = self._get_prop(self._spine_db_editor.get_arc_width, index)
if arc_width in (None, self._spine_db_editor.NOT_SPECIFIED):
return arc_width
min_val, val, max_val = arc_width
range_ = max_val - min_val
if range_ == 0:
return None
if val > 0:
return val / max_val, 1
return val / min_val, -1
[docs] def _get_vertex_radius(self, index=None):
vertex_radius = self._get_prop(self._spine_db_editor.get_vertex_radius, index)
if vertex_radius in (None, self._spine_db_editor.NOT_SPECIFIED):
return None
min_val, val, max_val = vertex_radius
range_ = max_val - min_val
if range_ == 0:
return 0
return (val - min_val) / range_
[docs] def _has_name(self):
return True
[docs] def set_up(self):
if self._has_name():
name = self._get_name()
if not name:
self.label_item.hide()
self._extent = 0.2 * self._given_extent
else:
if not self.has_dimensions:
self.label_item.show()
self.label_item.setPlainText(name)
self._extent = self._given_extent
else:
self.label_item.hide()
self._extent = 0.5 * self._given_extent
else:
self.label_item.hide()
self._extent = self._given_extent
self.setRect(-0.5 * self._extent, -0.5 * self._extent, self._extent, self._extent)
self._update_bg()
self.refresh_icon()
self.update_entity_pos()
[docs] def update_props(self, index):
color = self._get_color(index)
arc_width = self._get_arc_width(index)
vertex_radius = self._get_vertex_radius(index)
self._update_renderer(color, resize=True)
self._update_arcs(color, arc_width)
self._update_circle(color, vertex_radius)
[docs] def _update_bg(self):
bg_rect = QRectF(-0.5 * self._extent, -0.5 * self._extent, self._extent, self._extent)
if self._bg is not None:
self._bg.setRect(bg_rect)
self._bg.prepareGeometryChange()
self._bg.update()
return
if not self.has_dimensions:
self._bg = QGraphicsRectItem(bg_rect, self)
self._bg_brush = Qt.NoBrush
else:
self._bg = QGraphicsEllipseItem(bg_rect, self)
self._bg_brush = QGuiApplication.palette().button()
pen = self._bg.pen()
pen.setColor(Qt.transparent)
self._bg.setPen(pen)
self._bg.setFlag(QGraphicsItem.ItemStacksBehindParent, enabled=True)
[docs] def refresh_icon(self):
"""Refreshes the icon."""
color = self._get_color()
self._update_renderer(color)
[docs] def _update_renderer(self, color, resize=True):
if color is self._spine_db_editor.NOT_SPECIFIED:
color = color_from_index(0, 1, value=0)
self._renderer = self.db_mngr.entity_class_renderer(self.first_db_map, self.first_entity_class_id, color=color)
self._install_renderer()
[docs] def _install_renderer(self, resize=True):
self._svg_item.setSharedRenderer(self._renderer)
if not resize:
return
size = self._renderer.defaultSize()
scale = self._extent / max(size.width(), size.height())
self._svg_item.setScale(scale)
rect = self._svg_item.boundingRect()
self._svg_item.setTransformOriginPoint(rect.center())
self._svg_item.setPos(-rect.center())
[docs] def default_parameter_data(self):
"""Return data to put as default in a parameter table when this item is selected."""
if not self.db_map_ids:
return {}
return dict(
entity_class_name=self.entity_class_name,
entity_byname=DB_ITEM_SEPARATOR.join(self.byname),
database=self.first_db_map.codename,
)
[docs] def shape(self):
"""Returns a shape containing the entire bounding rect, to work better with icon transparency."""
path = QPainterPath()
path.setFillRule(Qt.WindingFill)
path.addRect(self._bg.boundingRect())
path.addPolygon(self.label_item.mapToItem(self, self.label_item.boundingRect()))
return path
[docs] def set_highlight_color(self, color):
self._highlight_color = color
[docs] def paint(self, painter, option, widget=None):
"""Shows or hides the selection halo."""
if option.state & (QStyle.StateFlag.State_Selected):
self._paint_as_selected()
option.state &= ~QStyle.StateFlag.State_Selected
else:
self._paint_as_deselected()
pen = self._bg.pen()
pen.setColor(self._highlight_color)
width = max(1, 10 / self.scale())
pen.setWidth(width)
self._bg.setPen(pen)
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)
self._rotate_svg_item()
self.update_entity_pos()
[docs] def update_entity_pos(self):
for arc_item in self.arc_items:
arc_item.ent_item.do_update_entity_pos()
self.do_update_entity_pos()
[docs] def do_update_entity_pos(self):
el_items = sorted(
(arc_item.el_item for arc_item in self.arc_items if arc_item.el_item is not self),
key=lambda x: x.entity_id(x.first_db_map) or 0,
)
dim_count = len(el_items)
if not dim_count:
return
new_pos_x = sum(el_item.pos().x() for el_item in el_items) / dim_count
new_pos_y = sum(el_item.pos().y() for el_item in el_items) / dim_count
offset = self._offset.value() if self._offset is not None else None
if offset:
el_item = el_items[0]
line = QLineF(QPointF(new_pos_x, new_pos_y), el_item.pos()).normalVector()
if offset < 0:
line.setAngle(line.angle() + 180)
line.setLength(3 * abs(offset) * self._extent)
new_pos_x, new_pos_y = line.x2(), line.y2()
self.setPos(new_pos_x, new_pos_y)
self.update_arcs_line()
[docs] def apply_zoom(self, factor):
"""Applies zoom.
Args:
factor (float): The zoom factor.
"""
factor = min(factor, 1)
self.setScale(factor)
[docs] def apply_rotation(self, angle, center):
"""Applies rotation.
Args:
angle (float): The angle in degrees.
center (QPointF): Rotates around this point.
"""
line = QLineF(center, self.pos())
line.setAngle(line.angle() + angle)
pos = line.p2()
self.set_pos(pos.x(), pos.y())
[docs] def mouseMoveEvent(self, event):
"""Moves the item and all connected arcs.
Args:
event (QGraphicsSceneMouseEvent)
"""
if event.buttons() & Qt.LeftButton == 0:
super().mouseMoveEvent(event)
return
delta = event.scenePos() - event.lastScenePos()
# Move selected items together
for item in self.scene().selectedItems():
if isinstance(item, (EntityItem)):
item.move_by(delta.x(), delta.y())
[docs] def update_arcs_line(self):
"""Moves arc items."""
for item in self.arc_items:
item.update_line()
color = self._get_color()
arc_width = self._get_arc_width()
self._update_arcs(color, arc_width)
[docs] def _update_arcs(self, color, arc_width):
if color not in (None, self._spine_db_editor.NOT_SPECIFIED):
for item in self.arc_items:
item.update_color(color)
if arc_width not in (None, self._spine_db_editor.NOT_SPECIFIED):
width, sign = arc_width
factor = 0.75 * (0.5 + 0.5 * width) * self._extent
switched = False
for item in self.arc_items:
item.apply_value(factor, sign)
if not switched:
switched = True
sign = -sign
[docs] def _update_circle(self, color, vertex_radius):
if color is self._spine_db_editor.NOT_SPECIFIED:
color = color_from_index(0, 1, value=0)
else:
color = QColor(color)
circle_extent = 2 * (0.5 + 0.5 * vertex_radius) * self._extent if vertex_radius is not None else 0
self._circle_item.setRect(-circle_extent / 2, -circle_extent / 2, circle_extent, circle_extent)
color.setAlphaF(0.5)
self._circle_item.setBrush(color)
[docs] def itemChange(self, change, value):
"""
Keeps track of item's movements on the scene. Rotates svg item if the relationship is 2D.
This makes it possible to define e.g. an arow icon for relationships that express direction.
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
self._rotate_svg_item()
return super().itemChange(change, value)
[docs] def setVisible(self, on):
"""Sets visibility status for this item and all arc items.
Args:
on (bool)
"""
super().setVisible(on)
for arc_item in self.arc_items:
arc_item.setVisible(arc_item.el_item.isVisible() and arc_item.ent_item.isVisible())
[docs] def remove_db_map_ids(self, db_map_ids):
"""Removes db_map_ids."""
self._removed_db_map_ids += tuple(db_map_ids)
self.setToolTip(self._make_tool_tip())
[docs] def add_db_map_ids(self, db_map_ids):
for db_map_id in db_map_ids:
if db_map_id not in self._db_map_ids:
self._db_map_ids += (db_map_id,)
else:
self._removed_db_map_ids = tuple(x for x in self._removed_db_map_ids if x != db_map_id)
self.setToolTip(self._make_tool_tip())
[docs] def _rotate_svg_item(self):
arc_items_as_ent = [x for x in self.arc_items if x.ent_item is self]
if len(arc_items_as_ent) != 2 or self.first_id is None:
self._svg_item.setRotation(0)
return
first_dimension = self.dimension_name_list[0]
element1 = arc_items_as_ent[0].el_item
element2 = arc_items_as_ent[1].el_item
if element1.entity_class_name == first_dimension:
start = element1.pos()
end = element2.pos()
else:
start = element2.pos()
end = element1.pos()
line = QLineF(start, end)
self._svg_item.setRotation(-line.angle())
[docs] def mouseDoubleClickEvent(self, e):
connect_entities_menu = QMenu(self._spine_db_editor)
title = TitleWidgetAction("Connect entities", self._spine_db_editor)
connect_entities_menu.addAction(title)
connect_entities_menu.triggered.connect(self._start_connecting_entities)
self._refresh_db_map_entity_class_lists()
self._populate_connect_entities_menu(connect_entities_menu)
connect_entities_menu.popup(e.screenPos())
[docs] def _duplicate(self):
self._spine_db_editor.duplicate_entity(self)
[docs] def _refresh_db_map_entity_class_lists(self):
self._db_map_entity_class_lists.clear()
db_map_entity_ids = {db_map: {id_} for db_map, id_ in self.db_map_ids}
entity_ids_per_class = {}
for db_map, ents in self.db_mngr.find_cascading_entities(db_map_entity_ids).items():
for ent in ents:
entity_ids_per_class.setdefault((db_map, ent["class_id"]), set()).add(ent["id"])
db_map_entity_class_ids = {db_map: self.entity_class_ids(db_map) for db_map in self.db_maps}
for db_map, ent_clss in self.db_mngr.find_cascading_entity_classes(db_map_entity_class_ids).items():
for ent_cls in ent_clss:
ent_cls = ent_cls._extended()
ent_cls["dimension_id_list"] = list(ent_cls["dimension_id_list"])
ent_cls["entity_ids"] = entity_ids_per_class.get((db_map, ent_cls["id"]), set())
self._db_map_entity_class_lists.setdefault(ent_cls["name"], []).append((db_map, ent_cls))
[docs] def _populate_expand_collapse_menu(self, menu):
"""
Populates the 'Expand' or 'Collapse' menu.
Args:
menu (QMenu)
"""
if not self._db_map_entity_class_lists:
menu.setEnabled(False)
return
menu.setEnabled(True)
menu.addAction("All")
menu.addSeparator()
for name, db_map_ent_clss in sorted(self._db_map_entity_class_lists.items()):
db_map, ent_cls = next(iter(db_map_ent_clss))
icon = self.db_mngr.entity_class_icon(db_map, ent_cls["id"])
menu.addAction(icon, name).setEnabled(any(rel_cls["entity_ids"] for (db_map, rel_cls) in db_map_ent_clss))
[docs] def _get_db_map_entity_ids_to_expand_or_collapse(self, action):
if action.text() == "All":
return {
(db_map, id_)
for db_map_ent_clss in self._db_map_entity_class_lists.values()
for db_map, ent_cls in db_map_ent_clss
for id_ in ent_cls["entity_ids"]
}
db_map_ent_clss = self._db_map_entity_class_lists.get(action.text())
if db_map_ent_clss is not None:
return {(db_map, id_) for db_map, ent_cls in db_map_ent_clss for id_ in ent_cls["entity_ids"]}
return ()
@Slot(QAction)
[docs] def _expand(self, action):
db_map_entity_ids = self._get_db_map_entity_ids_to_expand_or_collapse(action)
self._spine_db_editor.expand_graph(db_map_entity_ids)
@Slot(QAction)
[docs] def _collapse(self, action):
db_map_entity_ids = self._get_db_map_entity_ids_to_expand_or_collapse(action)
self._spine_db_editor.collapse_graph(db_map_entity_ids)
@Slot(QAction)
[docs] def _start_connecting_entities(self, action):
class_name, db_name = action.text().split("@")
db_map_ent_cls_lst = self._db_map_entity_class_lists[class_name]
db_map, ent_cls = next(
iter((db_map, ent_cls) for db_map, ent_cls in db_map_ent_cls_lst if db_map.codename == db_name)
)
self._spine_db_editor.start_connecting_entities(db_map, ent_cls, self)
[docs]class ArcItem(QGraphicsPathItem):
"""Connects two EntityItems."""
def __init__(self, ent_item, el_item, width):
"""
Args:
ent_item (spinetoolbox.widgets.graph_view_graphics_items.EntityItem): entity item
el_item (spinetoolbox.widgets.graph_view_graphics_items.EntityItem): element item
width (float): Preferred line width
"""
super().__init__()
self.ent_item = ent_item
self.el_item = el_item
self._original_width = float(width)
self._pen = self._make_pen()
self.setPen(self._pen)
self.setZValue(-2)
self._scaling_factor = 1
self._gradient = QGraphicsPathItem(self)
self._gradient.setPen(Qt.NoPen)
self._gradient_position = 0.5
self._gradient_width = 1
self._gradient_sign = 1
ent_item.add_arc_item(self)
el_item.add_arc_item(self)
self.setCursor(Qt.ArrowCursor)
self.update_line()
[docs] def clone(self, entity_items):
ent_item = entity_items[self.ent_item.db_map_ids]
el_item = entity_items[self.el_item.db_map_ids]
return type(self)(ent_item, el_item, self._original_width)
[docs] def _make_pen(self):
pen = QPen()
pen.setWidthF(self._original_width)
color = QGuiApplication.palette().color(QPalette.Normal, QPalette.WindowText)
color.setAlphaF(0.8)
pen.setColor(color)
pen.setStyle(Qt.SolidLine)
pen.setCapStyle(Qt.RoundCap)
return pen
[docs] def moveBy(self, dx, dy):
"""Does nothing. This item is not moved the regular way, but follows the EntityItems it connects."""
[docs] def update_line(self):
path = QPainterPath(self.ent_item.pos())
path.lineTo(self.el_item.pos())
self.setPath(path)
self._do_move_gradient()
[docs] def update_color(self, color):
self._pen.setColor(color)
self.setPen(self._pen)
color = QColor(color)
color.setAlphaF(0.5)
self._gradient.setBrush(color)
[docs] def apply_value(self, factor, sign):
self._update_width()
self._move_gradient(factor, sign)
[docs] def mousePressEvent(self, event):
"""Accepts the event so it's not propagated."""
event.accept()
[docs] def other_item(self, item):
return {self.ent_item: self.el_item, self.el_item: self.ent_item}.get(item)
[docs] def apply_zoom(self, factor):
"""Applies zoom.
Args:
factor (float): The zoom factor.
"""
self._scaling_factor = max(factor, 1)
self._update_width()
[docs] def _update_width(self):
width = self._original_width / self._scaling_factor
self._pen.setWidthF(width)
self.setPen(self._pen)
[docs] def _move_gradient(self, factor, sign):
self._gradient_sign = sign
self._gradient_width = max(1, factor)
self._gradient_position += 0.1 * self._gradient_sign / self._scaling_factor
if self._gradient_position > 1:
self._gradient_position -= 1
elif self._gradient_position < 0:
self._gradient_position += 1
self._do_move_gradient()
[docs] def _do_move_gradient(self):
width = self._original_width * self._gradient_width / self._scaling_factor
init_pos, final_pos = self.ent_item.pos(), self.el_item.pos()
line = QLineF(init_pos, final_pos)
line.translate(self._gradient_position * line.dx(), self._gradient_position * line.dy())
line.setLength(width)
line.translate(-line.dx() / 2, -line.dy() / 2)
if self._gradient_sign < 0:
line.setPoints(line.p2(), line.p1())
normal = line.normalVector()
normal.translate(-normal.dx() / 2, -normal.dy() / 2)
path = QPainterPath(line.p2())
path.lineTo(normal.p1())
path.lineTo(normal.p2())
self._gradient.setPath(path)
[docs]class CrossHairsItem(EntityItem):
"""Creates new relationships directly in the graph."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=False)
self.setZValue(2)
self._current_icon = None
@property
[docs] def entity_class_name(self):
return None
@property
[docs] def name(self):
return None
@property
[docs] def has_dimensions(self):
return False
[docs] def _has_name(self):
return False
[docs] def refresh_icon(self):
self._renderer = self.db_mngr.get_icon_mngr(self.first_db_map).icon_renderer("\uf05b", 0)
self._install_renderer()
[docs] def set_plus_icon(self):
self.set_icon("\uf067", Qt.blue)
[docs] def set_check_icon(self):
self.set_icon("\uf00c", Qt.green)
[docs] def set_normal_icon(self):
self.set_icon("\uf05b")
[docs] def set_ban_icon(self):
self.set_icon("\uf05e", Qt.red)
[docs] def set_icon(self, unicode, color=0):
"""Refreshes the icon."""
if (unicode, color) == self._current_icon:
return
self._renderer = self.db_mngr.get_icon_mngr(self.first_db_map).icon_renderer(unicode, color)
self._install_renderer()
self._current_icon = (unicode, color)
[docs] def _snap(self, x, y):
return (x, y)
[docs] def mouseMoveEvent(self, event):
delta = event.scenePos() - self.scenePos()
self.move_by(delta.x(), delta.y())
[docs]class CrossHairsEntityItem(EntityItem):
"""Represents the relationship that's being created using the CrossHairsItem."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=False)
@property
[docs] def has_dimensions(self):
return True
[docs] def _has_name(self):
return False
[docs] def refresh_icon(self):
"""Refreshes the icon."""
el_items = [arc_item.el_item for arc_item in self.arc_items]
dimension_name_list = tuple(
el_item.entity_class_name for el_item in el_items if not isinstance(el_item, CrossHairsItem)
)
self._renderer = self.db_mngr.get_icon_mngr(self.first_db_map).multi_class_renderer(dimension_name_list)
self._install_renderer()
[docs]class CrossHairsArcItem(ArcItem):
"""Connects a CrossHairsEntityItem with the CrossHairsItem,
and with all the EntityItem's in the relationship so far.
"""
[docs] def _make_pen(self):
pen = super()._make_pen()
pen.setStyle(Qt.DotLine)
color = pen.color()
color.setAlphaF(0.5)
pen.setColor(color)
return pen
[docs]class EntityLabelItem(QGraphicsTextItem):
"""Provides a label for EntityItem."""
[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)
self.bg_color = QGuiApplication.palette().color(QPalette.Normal, QPalette.ToolTipBase)
self.bg_color.setAlphaF(0.8)
self.bg.setBrush(QBrush(self.bg_color))
self.bg.setPen(Qt.NoPen)
self.bg.setFlag(QGraphicsItem.ItemStacksBehindParent)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=False)
self.setAcceptHoverEvents(False)
[docs] def boundingRect(self):
if not self.isVisible():
return QRectF()
return super().boundingRect()
[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]class BgItem(QGraphicsRectItem):
[docs] _getter_setter = {
Anchor.TL: ("topLeft", "setTopLeft"),
Anchor.TR: ("topRight", "setTopRight"),
Anchor.BL: ("bottomLeft", "setBottomLeft"),
Anchor.BR: ("bottomRight", "setBottomRight"),
}
[docs] _cursors = {
Anchor.TL: Qt.SizeFDiagCursor,
Anchor.TR: Qt.SizeBDiagCursor,
Anchor.BL: Qt.SizeBDiagCursor,
Anchor.BR: Qt.SizeFDiagCursor,
}
def __init__(self, svg, parent=None):
super().__init__(parent)
self._renderer = QSvgRenderer()
self._svg_item = _ResizableQGraphicsSvgItem(self)
self.svg = svg
_loading_ok = self._renderer.load(QByteArray(self.svg))
self._svg_item.setCacheMode(QGraphicsItem.CacheMode.NoCache) # Needed for the exported pdf to be vector
self._svg_item.setSharedRenderer(self._renderer)
self._scaling_factor = 1
size = self._renderer.defaultSize()
self.setRect(0, 0, size.width(), size.height())
self.setZValue(-1000)
self.setPen(Qt.NoPen)
self.setAcceptHoverEvents(True)
self.setFlag(QGraphicsItem.ItemIsMovable, True)
self._resizers = {anchor: _Resizer(parent=self) for anchor in self.Anchor}
for anchor, resizer in self._resizers.items():
resizer.resized.connect(lambda delta, strong, anchor=anchor: self._resize(anchor, delta, strong))
resizer.setCursor(self._cursors[anchor])
resizer.hide()
[docs] def clone(self):
other = type(self)(self.svg)
other.fit_rect(self.scene_rect())
return other
[docs] def hoverEnterEvent(self, ev):
super().hoverEnterEvent(ev)
for resizer in self._resizers.values():
resizer.show()
[docs] def hoverLeaveEvent(self, ev):
super().hoverLeaveEvent(ev)
for resizer in self._resizers.values():
resizer.hide()
[docs] def apply_zoom(self, factor):
self._scaling_factor = factor
self._place_resizers()
[docs] def _place_resizers(self):
for anchor, resizer in self._resizers.items():
getter, _ = self._getter_setter[anchor]
resizer.setPos(
getattr(self.rect(), getter)() - getattr(resizer.rect(), getter)() / self.scale() / self._scaling_factor
)
[docs] def _resize(self, anchor, delta, strong):
delta /= self.scale() * self._scaling_factor
rect = self.rect()
getter, setter = self._getter_setter[anchor]
get_point = getattr(rect, getter)
set_point = getattr(rect, setter)
set_point(get_point() + delta)
self._do_resize(rect, strong)
[docs] def _do_resize(self, rect, strong):
if strong:
self._svg_item.resize(rect.width(), rect.height())
self._svg_item.setPos(rect.topLeft())
self.setPen(Qt.NoPen)
else:
self.setPen(QPen(Qt.DashLine))
self.setRect(rect)
self.prepareGeometryChange()
self.update()
self._place_resizers()
[docs] def fit_rect(self, rect):
if not isinstance(rect, QRectF):
rect = QRectF(*rect)
self._do_resize(rect, True)
[docs] def scene_rect(self):
return self.mapToScene(self.rect()).boundingRect()
[docs]class _ResizableQGraphicsSvgItem(QGraphicsSvgItem):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._width = 0
self._height = 0
self.setFlag(QGraphicsItem.ItemStacksBehindParent, True)
[docs] def resize(self, width, height):
self._width = width
self._height = height
self.prepareGeometryChange()
self.update()
[docs] def setSharedRenderer(self, renderer):
super().setSharedRenderer(renderer)
self._width = renderer.defaultSize().width()
self._height = renderer.defaultSize().height()
[docs] def boundingRect(self):
return QRectF(0, 0, self._width, self._height)
[docs] def paint(self, painter, options, widget):
self.renderer().render(painter, self.boundingRect())
[docs]class _Resizer(QGraphicsRectItem):
[docs] class SignalsProvider(QObject):
[docs] resized = Signal(QPointF, bool)
def __init__(self, rect=QRectF(0, 0, 20, 20), parent=None):
super().__init__(rect, parent)
self._original_rect = self.rect()
self._press_pos = None
self.setFlag(QGraphicsItem.ItemIsMovable, True)
self.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
self._signal_provider = self.SignalsProvider()
self.resized = self._signal_provider.resized
[docs] def mousePressEvent(self, ev):
super().mousePressEvent(ev)
self._press_pos = ev.pos()
[docs] def mouseMoveEvent(self, ev):
super().mouseMoveEvent(ev)
delta = ev.pos() - self._press_pos
self._signal_provider.resized.emit(delta, False)
[docs] def mouseReleaseEvent(self, ev):
super().mouseReleaseEvent(ev)
delta = ev.pos() - self._press_pos
self._signal_provider.resized.emit(delta, True)