Source code for spinetoolbox.link

######################################################################################################################
# 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 QGraphicsScene."""
import functools
from math import sin, cos, pi, radians
from PySide6.QtCore import Qt, Slot, QPointF, QLineF, QRectF, QVariantAnimation
from PySide6.QtWidgets import (
    QGraphicsItem,
    QGraphicsPathItem,
    QGraphicsTextItem,
    QGraphicsEllipseItem,
    QStyle,
    QToolTip,
    QGraphicsColorizeEffect,
)
from PySide6.QtGui import QColor, QPen, QBrush, QPainterPath, QLinearGradient, QFont, QCursor, QPainterPathStroker
from PySide6.QtSvgWidgets import QGraphicsSvgItem
from PySide6.QtSvg import QSvgRenderer
from spinetoolbox.helpers import color_from_index
from .project_item_icon import ConnectorButton


[docs]JUMP_COLOR = color_from_index(1, 2, base_hue=60)
[docs]class LinkBase(QGraphicsPathItem): """Base class for Link and LinkDrawer. Mainly provides the ``update_geometry`` method for 'drawing' the link on the scene. """
[docs] _COLOR = QColor(0, 0, 0, 0)
def __init__(self, toolbox, src_connector, dst_connector): """ Args: toolbox (ToolboxUI): main UI class instance src_connector (ConnectorButton, optional): Source connector button dst_connector (ConnectorButton): Destination connector button """ super().__init__() self._toolbox = toolbox self.src_connector = src_connector self.dst_connector = dst_connector self.arrow_angle = pi / 4 self.setCursor(Qt.PointingHandCursor) self._guide_path = None self._pen = QPen(self._COLOR) self._pen.setWidthF(self.magic_number) self._pen.setJoinStyle(Qt.MiterJoin) self.setPen(self._pen) self.selected_pen = QPen(self.outline_color, 2, Qt.DotLine) self.normal_pen = QPen(self.outline_color, 1) self._outline = QGraphicsPathItem(self) self._outline.setFlag(QGraphicsPathItem.ItemStacksBehindParent) self._outline.setPen(self.normal_pen) self._stroker = QPainterPathStroker() self._stroker.setWidth(self.magic_number) self._stroker.setJoinStyle(Qt.MiterJoin) self._shape = QPainterPath()
[docs] def shape(self): return self._shape
@property
[docs] def outline_color(self): return self._COLOR.darker()
@property
[docs] def magic_number(self): return 0.625 * self.src_rect.width()
@property
[docs] def src_rect(self): """Returns the scene rectangle of the source connector.""" return self.src_connector.sceneBoundingRect()
@property
[docs] def src_center(self): """Returns the center point of the source rectangle.""" return self.src_rect.center()
@property
[docs] def dst_rect(self): """Returns the scene rectangle of the destination connector.""" return self.dst_connector.sceneBoundingRect()
@property
[docs] def dst_center(self): """Returns the center point of the destination rectangle.""" return self.dst_rect.center()
[docs] def moveBy(self, _dx, _dy): """Does nothing. This item is not moved the regular way, but follows the ConnectorButtons it connects."""
[docs] def update_geometry(self, curved_links=None): """Updates geometry.""" self.prepareGeometryChange() if curved_links is None: qsettings = self._toolbox.qsettings() curved_links = qsettings.value("appSettings/curvedLinks", defaultValue="false") == "true" self._guide_path = self._make_guide_path(curved_links) self._do_update_geometry()
[docs] def guide_path(self): """For tests.""" return self._guide_path
[docs] def _do_update_geometry(self): """Sets the path for this item.""" path = QPainterPath(self._guide_path) self._add_arrow_path(path) self._add_ellipse_path(path) self.setPath(path) stroke = self._stroker.createStroke(path) self._outline.setPath(stroke) self._shape.clear() self._shape.addPath(stroke)
[docs] def _add_ellipse_path(self, path): """Adds an ellipse for the link's base. Args: QPainterPath """ radius = 0.5 * self.magic_number rect = QRectF(0, 0, radius, radius) rect.moveCenter(self.src_center) path.addEllipse(rect)
[docs] def _get_joint_angle(self): return radians(self._guide_path.angleAtPercent(0.99))
[docs] def _add_arrow_path(self, path): """Returns an arrow path for the link's tip. Args: QPainterPath """ angle = self._get_joint_angle() arrow_p0 = self.dst_center + 0.5 * self.magic_number * self._get_dst_offset() d1 = QPointF(sin(angle + self.arrow_angle), cos(angle + self.arrow_angle)) d2 = QPointF(sin(angle + (pi - self.arrow_angle)), cos(angle + (pi - self.arrow_angle))) arrow_diag = 1.5 / sin(self.arrow_angle) arrow_p1 = arrow_p0 - d1 * arrow_diag arrow_p2 = arrow_p0 - d2 * arrow_diag path.moveTo(arrow_p1) path.lineTo(arrow_p0) path.lineTo(arrow_p2) path.closeSubpath()
@staticmethod
[docs] def _get_offset(button): return {"top": QPointF(0, -1), "left": QPointF(-1, 0), "bottom": QPointF(0, 1), "right": QPointF(1, 0)}[ button.position ]
[docs] def _get_src_offset(self): return self._get_offset(self.src_connector)
[docs] def _get_dst_offset(self): return self._get_offset(self.dst_connector)
[docs] def _find_new_point(self, points, target): """Finds a new point that approximates points to target in a smooth trajectory. Returns the new point, or None if no need for approximation. Args: points (list(QPointF)) target (QPointF) Returns: QPointF or None """ line = QLineF(*points[-2:]) line_to_target = QLineF(points[-1], target) angle = line.angleTo(line_to_target) corrected_angle = angle if angle < 180 else angle - 360 if abs(corrected_angle) <= 90: return None sign = abs(corrected_angle) // corrected_angle new_angle = line.angle() + 90 * sign foot = sin if angle > 0 else cos new_length = max(abs(foot(radians(angle))) * line_to_target.length(), 3 * self.magic_number) line_to_target.setAngle(new_angle) line_to_target.setLength(new_length) return line_to_target.center()
[docs] def _close_enough(self, p1, p2): return (p1 - p2).manhattanLength() < 2 * self.magic_number
[docs] def _make_guide_path(self, curved_links=False): """Returns a 'narrow' path connecting this item's source and destination. Args: curved_links (bool): Whether the path should follow a curved line or just a straight line Returns: QPainterPath """ c_factor = 3 * self.magic_number src = self.src_center + c_factor * self._get_src_offset() dst = self.dst_center + c_factor * self._get_dst_offset() src_points = [self.src_center, src] dst_points = [self.dst_center, dst] while True: # Bring source points closer to destination new_src = self._find_new_point(src_points, dst) if new_src is not None: src_points.append(new_src) src = new_src if self._close_enough(src, dst): break # Bring destination points closer to source new_dst = self._find_new_point(dst_points, src) if new_dst is not None: dst_points.append(new_dst) dst = new_dst if self._close_enough(src, dst): break if new_src is new_dst is None: break points = src_points + list(reversed(dst_points)) points = list(map(lambda xy: QPointF(*xy), dict.fromkeys((p.x(), p.y()) for p in points))) if len(points) == 1: path = QPainterPath(points[0]) path.lineTo(points[0] + QPointF(1, 1)) return path # Correct last point so it doesn't go beyond the arrow head = QPainterPath(points[-2]) head.lineTo(points[-1]) points[-1] = head.pointAtPercent(1 - head.percentAtLength(self.magic_number)) # Make path path = QPainterPath(points.pop(0)) if not curved_links: for p1 in points: path.lineTo(p1) return path for p1, p2 in zip(points[:-2], points[1:-1]): path.quadTo(p1, (p1 + p2) / 2) if len(points) == 1: path.lineTo(points[-1]) else: path.quadTo(points[-2], points[-1]) return path
[docs] def itemChange(self, change, value): """Wipes out the link when removed from scene.""" if change == QGraphicsItem.GraphicsItemChange.ItemSceneHasChanged and value is None: self.wipe_out() return super().itemChange(change, value)
[docs] def wipe_out(self): """Removes any trace of this item from the system."""
[docs]class _IconBase(QGraphicsEllipseItem): """Base class for icons to show over a Link.""" def __init__(self, x, y, w, h, parent, tooltip=None, active=True): super().__init__(x, y, w, h, parent) palette = qApp.palette() # pylint: disable=undefined-variable brush = palette.highlight() if active else palette.mid() self._fg_color = brush.color() if tooltip: self.setToolTip(tooltip) self.setAcceptHoverEvents(True) self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=False) self.setBrush(palette.window())
[docs] def hoverEnterEvent(self, event): QToolTip.showText(event.screenPos(), self.toolTip())
[docs] def hoverLeaveEvent(self, event): QToolTip.hideText()
[docs]class _SvgIcon(_IconBase): """An SVG icon to show over a Link.""" def __init__(self, parent, extent, path, tooltip=None, active=False): super().__init__(0, 0, extent, extent, parent, tooltip=tooltip, active=active) self._svg_item = QGraphicsSvgItem(self) self._renderer = QSvgRenderer() self._renderer.load(path) self._colorizer = QGraphicsColorizeEffect() self._colorizer.setColor(self._fg_color) self._svg_item.setSharedRenderer(self._renderer) self._svg_item.setGraphicsEffect(self._colorizer) scale = 0.8 * self.rect().width() / self._renderer.defaultSize().width() self._svg_item.setScale(scale) self._svg_item.setPos(self.sceneBoundingRect().center() - self._svg_item.sceneBoundingRect().center()) self.setPen(Qt.NoPen)
[docs] def wipe_out(self): """Cleans up icon's resources.""" self._svg_item.deleteLater() self._renderer.deleteLater() self.scene().removeItem(self)
[docs]class _TextIcon(_IconBase): """A font awesome icon to show over a Link.""" def __init__(self, parent, extent, char, tooltip=None, active=False): super().__init__(0, 0, extent, extent, parent, tooltip=tooltip, active=active) self._text_item = QGraphicsTextItem(self) font = QFont("Font Awesome 5 Free Solid", weight=QFont.Bold) self._text_item.setFont(font) self._text_item.setDefaultTextColor(self._fg_color) self._text_item.setPlainText(char) self._text_item.setPos(self.sceneBoundingRect().center() - self._text_item.sceneBoundingRect().center()) self.setPen(Qt.NoPen)
[docs] def wipe_out(self): """Cleans up icon's resources.""" self._text_item.deleteLater() self.scene().removeItem(self)
[docs]class _WarningTextIcon(_TextIcon): """A font awesome icon to show over a Link.""" def __init__(self, parent, extent, char, tooltip): super().__init__(parent, extent, char, tooltip, active=True) self._fg_color = QColor("red") self._text_item.setDefaultTextColor(self._fg_color)
[docs]class LinkDrawerBase(LinkBase): """A base class for items intended for drawing links between project items.""" def __init__(self, toolbox): """ Args: toolbox (ToolboxUI): main UI class instance """ super().__init__(toolbox, None, None) self.tip = None self.setZValue(1) # A drawer should be on top of every other item. @property
[docs] def src_rect(self): if not self.src_connector: return QRectF() return self.src_connector.sceneBoundingRect()
@property
[docs] def dst_rect(self): if not self.dst_connector: return QRectF() return self.dst_connector.sceneBoundingRect()
@property
[docs] def dst_center(self): if not self.dst_connector: return self.tip # If link drawer tip is on a connector button, this makes # the tip 'snap' to the center of the connector button return self.dst_rect.center()
[docs] def _get_dst_offset(self): if self.dst_connector is None: return QPointF(0, 0) return super()._get_dst_offset()
[docs] def wake_up(self, src_connector): """Sets the source connector, shows this item and adds it to the scene. After calling this, the scene is in link drawing mode. Args: src_connector (ConnectorButton): source connector """ view = self._toolbox.ui.graphicsView self.tip = view.mapToScene(view.mapFromGlobal(QCursor.pos())) self.src_connector = src_connector scene = self.src_connector.scene() scene.addItem(self) self._stroker.setWidth(self.magic_number) self._pen.setWidthF(self.magic_number) self.setPen(self._pen) self.update_geometry() self.show() scene.link_about_to_be_drawn.emit()
[docs] def sleep(self): """Removes this drawer from the scene, clears its source and destination connectors, and hides it. After calling this, the scene is no longer in link drawing mode. """ scene = self.scene() scene.removeItem(self) scene.link_drawer = self.src_connector = self.dst_connector = self.tip = None self.hide() scene.link_drawing_finished.emit()
[docs]class ConnectionLinkDrawer(LinkDrawerBase): """An item for drawing connection links between project items."""
[docs] _COLOR = LINK_COLOR.lighter()
def __init__(self, toolbox): """ Args: toolbox (ToolboxUI): main UI class instance """ super().__init__(toolbox) self._pen.setBrush(QBrush(self._COLOR))
[docs] def wake_up(self, src_connector): super().wake_up(src_connector) self.src_connector.set_friend_connectors_enabled(False)
[docs] def sleep(self): self.src_connector.set_friend_connectors_enabled(True) super().sleep()
[docs]class JumpLinkDrawer(LinkDrawerBase): """An item for drawing jump connections between project items."""
[docs] _COLOR = JUMP_COLOR.lighter()
def __init__(self, toolbox): """ Args: toolbox (ToolboxUI): main UI class instance """ super().__init__(toolbox) self._pen.setBrush(QBrush(self._COLOR))
[docs]def _regular_polygon_points(n, side, initial_angle=0): internal_angle = 180 * (n - 2) / n angle_inc = 180 - internal_angle current_angle = initial_angle point = QPointF(0, 0) for _ in range(n): yield point line = QLineF(point, point + QPointF(side, 0)) line.setAngle(current_angle) point = line.p2() current_angle += angle_inc