Skip to content

Commit

Permalink
New feature: Groups
Browse files Browse the repository at this point in the history
  • Loading branch information
ptsavol committed Sep 11, 2024
1 parent 3e6b75f commit db8a7b0
Show file tree
Hide file tree
Showing 25 changed files with 1,013 additions and 317 deletions.
234 changes: 234 additions & 0 deletions spinetoolbox/group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
######################################################################################################################
# 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/>.
######################################################################################################################

"""Class for drawing an item group on QGraphicsScene."""
from PySide6.QtCore import Qt, QMarginsF, QRectF
from PySide6.QtGui import QBrush, QPen, QAction, QPainterPath, QTransform
from PySide6.QtWidgets import (
QGraphicsItem,
QGraphicsRectItem,
QGraphicsTextItem,
QGraphicsDropShadowEffect,
QStyle
)
from .project_item_icon import ProjectItemIcon


class Group(QGraphicsRectItem):

FONT_SIZE_PIXELS = 12 # pixel size to prevent font scaling by system

def __init__(self, toolbox, name, item_names):
super().__init__()
print(f"toolbox:{toolbox}")
self._toolbox = toolbox
self._name = name
self._item_names = item_names # strings
self._items = dict() # QGraphicsItems
conns = self._toolbox.project.connections + self._toolbox.project.jumps
for name in item_names:
try:
icon = self._toolbox.project.get_item(name).get_icon()
self._items[name] = icon
except KeyError: # name refers to a link or to a jump
link_icons = [link.graphics_item for link in conns if link.name == name]
self._items[name] = link_icons[0]
for item_icon in self._items.values():
item_icon.my_groups.add(self)
self._n_items = len(self._items)
disband_action = QAction("Ungroup items")
disband_action.triggered.connect(lambda checked=False, group_name=self.name: self._toolbox.ui.graphicsView.push_disband_group_command(checked, group_name))
rename_group_action = QAction("Rename group...")
rename_group_action.triggered.connect(lambda checked=False, group_name=self.name: self._toolbox.rename_group(checked, group_name))
self._actions = [disband_action, rename_group_action]
self.margins = QMarginsF(0, 0, 0, 10.0) # left, top, right, bottom
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, enabled=True)
self.setAcceptHoverEvents(True)
self.setZValue(-10)
self.name_item = QGraphicsTextItem(self._name, parent=self)
self.set_name_attributes()
self.setRect(self.rect())
self._reposition_name_item()
self.setBrush(self._toolbox.ui.graphicsView.scene().bg_color.lighter(107))
self.normal_pen = QPen(QBrush("gray"), 1, Qt.PenStyle.SolidLine)
self.selected_pen_for_ui_lite = QPen(QBrush("gray"), 5, Qt.PenStyle.DashLine)
self.selected_pen = QPen(QBrush("black"), 1, Qt.PenStyle.DashLine)
self.setPen(self.normal_pen)
self.set_graphics_effects()

@property
def name(self):
return self._name

@name.setter
def name(self, new_name):
self._name = new_name
self.name_item.setPlainText(new_name)

@property
def items(self):
return self._items.values()

@property
def item_names(self):
return list(self._items.keys())

@property
def project_items(self):
return [item for item in self.items if isinstance(item, ProjectItemIcon)]

@property
def n_items(self):
return len(self._items)

def actions(self):
return self._actions

def set_name_attributes(self):
"""Sets name item attributes (font, size, style, alignment)."""
self.name_item.setZValue(100)
font = self.name_item.font()
font.setPixelSize(self.FONT_SIZE_PIXELS)
font.setBold(True)
self.name_item.setFont(font)
option = self.name_item.document().defaultTextOption()
option.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.name_item.document().setDefaultTextOption(option)

def _reposition_name_item(self):
"""Sets name item position (left side on top of the group icon)."""
main_rect = self.boundingRect()
name_rect = self.name_item.sceneBoundingRect()
self.name_item.setPos(main_rect.left(), main_rect.y() - name_rect.height() - 4)

def add_item(self, name):
"""Adds item to this Group.
Args:
name (str): Project item or Link name
"""
try:
icon = self._toolbox.project.get_item(name).get_icon()
except KeyError: # name refers to a link or to a jump
conns = self._toolbox.project.connections + self._toolbox.project.jumps
link_icons = [link.graphics_item for link in conns if link.name == name]
icon = link_icons[0]
icon.my_groups.add(self)
self._items[name] = icon
self.update_group_rect()

def remove_item(self, name):
"""Removes item from this Group.
Args:
name (str): Project item name
"""
item = self._items.pop(name)
for conn in item.connectors.values():
for link in conn.outgoing_links():
if link.name in self._items.keys():
self._items.pop(link.name)
link.my_groups.remove(self)
for link in conn.incoming_links():
if link.name in self._items.keys():
self._items.pop(link.name)
link.my_groups.remove(self)
item.my_groups.remove(self)
self.update_group_rect()

def remove_all_items(self):
"""Removes all items (ProjectItemIcons) from this group."""
for item in self.project_items:
self.remove_item(item.name)

def update_group_rect(self):
"""Updates group rectangle and it's attributes when group member(s) is/are moved."""
self.setRect(self.rect())
self._reposition_name_item()

def rect(self):
"""Calculates the size of the rectangle for this group."""
united_rect = QRectF()
for item in self.items:
if isinstance(item, ProjectItemIcon):
united_rect = united_rect.united(item.name_item.sceneBoundingRect().united(item.sceneBoundingRect()))
else:
united_rect = united_rect.united(item.sceneBoundingRect())
rect_with_margins = united_rect.marginsAdded(self.margins)
return rect_with_margins

def set_graphics_effects(self):
shadow_effect = QGraphicsDropShadowEffect()
shadow_effect.setOffset(1)
shadow_effect.setEnabled(False)
self.setGraphicsEffect(shadow_effect)

def mousePressEvent(self, event):
"""Sets all items belonging to this group selected.
Args:
event (QMousePressEvent): Event
"""
event.accept()
path = QPainterPath()
path.addRect(self.rect())
self._toolbox.toolboxuibase.active_ui_window.ui.graphicsView.scene().setSelectionArea(path, QTransform())

def mouseReleaseEvent(self, event):
"""Accepts the event to prevent graphics view's mouseReleaseEvent from clearing the selections."""
event.accept()

def contextMenuEvent(self, event):
"""Opens context-menu in design mode."""
if self._toolbox.active_ui_mode == "toolboxuilite":
event.ignore() # Send context-menu request to graphics view
return
event.accept()
self.scene().clearSelection()
self.setSelected(True)
self._toolbox.show_project_or_item_context_menu(event.screenPos(), self)

def hoverEnterEvent(self, event):
"""Sets a drop shadow effect to icon when mouse enters group boundaries.
Args:
event (QGraphicsSceneMouseEvent): Event
"""
self.prepareGeometryChange()
self.graphicsEffect().setEnabled(True)
event.accept()

def hoverLeaveEvent(self, event):
"""Disables the drop shadow when mouse leaves group boundaries.
Args:
event (QGraphicsSceneMouseEvent): Event
"""
self.prepareGeometryChange()
self.graphicsEffect().setEnabled(False)
event.accept()

def to_dict(self):
"""Returns a dictionary mapping group name to item names."""
return {self.name: self.item_names}

def paint(self, painter, option, widget=None):
"""Sets a dashline pen when selected."""
if option.state & QStyle.StateFlag.State_Selected:
option.state &= ~QStyle.StateFlag.State_Selected
if self._toolbox.active_ui_mode == "toolboxui":
self.setPen(self.selected_pen)
elif self._toolbox.active_ui_mode == "toolboxuilite":
self.setPen(self.selected_pen_for_ui_lite)
else:
self.setPen(self.normal_pen)
super().paint(painter, option, widget)
36 changes: 22 additions & 14 deletions spinetoolbox/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ class LinkBase(QGraphicsPathItem):
"""

_COLOR = QColor(0, 0, 0, 0)
DEFAULT_LINK_SELECTION_PEN_W = 2
USER_MODE_LINK_SELECTION_PEN_W = 5

def __init__(self, toolbox, src_connector, dst_connector):
"""
Expand All @@ -53,20 +55,20 @@ def __init__(self, toolbox, src_connector, dst_connector):
self.src_connector = src_connector
self.dst_connector = dst_connector
self.arrow_angle = pi / 4
self.setCursor(Qt.PointingHandCursor)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self._guide_path = None
self._pen = QPen(self._COLOR)
self._pen.setWidthF(self.magic_number)
self._pen.setJoinStyle(Qt.MiterJoin)
self._pen.setJoinStyle(Qt.PenJoinStyle.MiterJoin)
self.setPen(self._pen)
self.selected_pen = QPen(self.outline_color, 2, Qt.DotLine)
self.selected_pen = QPen(self.outline_color, self.DEFAULT_LINK_SELECTION_PEN_W, Qt.PenStyle.DotLine)
self.normal_pen = QPen(self.outline_color, 1)
self._outline = QGraphicsPathItem(self)
self._outline.setFlag(QGraphicsPathItem.ItemStacksBehindParent)
self._outline.setFlag(QGraphicsPathItem.GraphicsItemFlag.ItemStacksBehindParent)
self._outline.setPen(self.normal_pen)
self._stroker = QPainterPathStroker()
self._stroker.setWidth(self.magic_number)
self._stroker.setJoinStyle(Qt.MiterJoin)
self._stroker.setJoinStyle(Qt.PenJoinStyle.MiterJoin)
self._shape = QPainterPath()

def shape(self):
Expand Down Expand Up @@ -100,6 +102,10 @@ def dst_center(self):
"""Returns the center point of the destination rectangle."""
return self.dst_rect.center()

def set_link_selection_pen_w(self, pen_width):
"""Sets selected links dash line width."""
self.selected_pen = QPen(self.outline_color, pen_width, Qt.PenStyle.DotLine)

def moveBy(self, _dx, _dy):
"""Does nothing. This item is not moved the regular way, but follows the ConnectorButtons it connects."""

Expand Down Expand Up @@ -131,7 +137,7 @@ def _add_ellipse_path(self, path):
"""Adds an ellipse for the link's base.
Args:
QPainterPath
path (QPainterPath)
"""
radius = 0.5 * self.magic_number
rect = QRectF(0, 0, radius, radius)
Expand All @@ -145,7 +151,7 @@ def _add_arrow_path(self, path):
"""Returns an arrow path for the link's tip.
Args:
QPainterPath
path (QPainterPath)
"""
angle = self._get_joint_angle()
arrow_p0 = self.dst_center + 0.5 * self.magic_number * self._get_dst_offset()
Expand Down Expand Up @@ -275,7 +281,7 @@ def __init__(self, x, y, w, h, parent, tooltip=None, active=True):
if tooltip:
self.setToolTip(tooltip)
self.setAcceptHoverEvents(True)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=False)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, enabled=False)
self.setBrush(palette.window())

def hoverEnterEvent(self, event):
Expand All @@ -300,7 +306,7 @@ def __init__(self, parent, extent, path, tooltip=None, active=False):
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)
self.setPen(Qt.PenStyle.NoPen)

def wipe_out(self):
"""Cleans up icon's resources."""
Expand All @@ -315,12 +321,12 @@ class _TextIcon(_IconBase):
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)
font = QFont("Font Awesome 5 Free Solid", weight=QFont.Weight.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)
self.setPen(Qt.PenStyle.NoPen)

def wipe_out(self):
"""Cleans up icon's resources."""
Expand All @@ -342,11 +348,12 @@ class JumpOrLink(LinkBase):

def __init__(self, toolbox, src_connector, dst_connector):
super().__init__(toolbox, src_connector, dst_connector)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=True)
self.setFlag(QGraphicsItem.ItemIsFocusable, enabled=True)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, enabled=True)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsFocusable, enabled=True)
self._icon_extent = 3 * self.magic_number
self._icons = []
self._anim = self._make_execution_animation()
self.my_groups = set()
self.update_geometry()

@property
Expand Down Expand Up @@ -385,6 +392,7 @@ def mousePressEvent(self, e):
"""
if any(isinstance(x, ConnectorButton) for x in self.scene().items(e.scenePos())):
e.ignore()
return

def contextMenuEvent(self, e):
"""Selects the link and shows context menu.
Expand All @@ -396,7 +404,7 @@ def contextMenuEvent(self, e):
self._toolbox.show_link_context_menu(e.screenPos(), self)

def paint(self, painter, option, widget=None):
"""Sets a dashed pen if selected."""
"""Sets a dotted pen when selected."""
if option.state & QStyle.StateFlag.State_Selected:
option.state &= ~QStyle.StateFlag.State_Selected
self._outline.setPen(self.selected_pen)
Expand Down
Loading

0 comments on commit db8a7b0

Please sign in to comment.