Skip to content

Commit

Permalink
implement copy&paste in graph editor
Browse files Browse the repository at this point in the history
fix port hovering during connecting ports
  • Loading branch information
cwiede committed Oct 22, 2020
1 parent cfe026a commit fcfb3ad
Show file tree
Hide file tree
Showing 6 changed files with 191 additions and 20 deletions.
7 changes: 6 additions & 1 deletion nexxT/core/BaseGraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@ def __init__(self):
self._nodes = OrderedDict()
self._connections = []

def _uniqueNodeName(self, nodeName):
def uniqueNodeName(self, nodeName):
"""
Given a suggested node name, return a unique node name based on this name.
:param nodeName: a node name string
:return: a unique node name string
"""
assertMainThread()
if not nodeName in self._nodes:
return nodeName
Expand Down
2 changes: 1 addition & 1 deletion nexxT/core/Graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def addNode(self, library, factoryFunction, suggestedName=None):
assertMainThread()
if suggestedName is None:
suggestedName = factoryFunction
name = super()._uniqueNodeName(suggestedName)
name = super().uniqueNodeName(suggestedName)
try:
propColl = self._properties.getChildCollection(name)
except PropertyCollectionChildNotFound:
Expand Down
18 changes: 14 additions & 4 deletions nexxT/core/SubConfiguration.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,24 @@ def getConfiguration(self):
return self._config

@staticmethod
def _connectionStringToTuple(con):
def connectionStringToTuple(con):
"""
Converts a connection string to a 4 tuple.
:param con: a string containing a connection "name1.port1 -> name2.port2"
:return: a 4-tuple ("name1", "port1", "name2", "port2")
"""
f, t = con.split("->")
fromNode, fromPort = f.strip().split(".")
toNode, toPort = t.strip().split(".")
return fromNode.strip(), fromPort.strip(), toNode.strip(), toPort.strip()

@staticmethod
def _tupleToConnectionString(connection):
def tupleToConnectionString(connection):
"""
Converts a 4-tuple connection to a string.
:param connection: a 4-tuple ("name1", "port1", "name2", "port2")
:return: a string "name1.port1 -> name2.port2"
"""
return "%s.%s -> %s.%s" % connection

def load(self, cfg, compositeLookup):
Expand Down Expand Up @@ -134,7 +144,7 @@ def load(self, cfg, compositeLookup):
# make sure that the filter is instantiated and the port information is updated immediately
self._graph.getMockup(n["name"]).createFilterAndUpdate()
for c in cfg["connections"]:
contuple = self._connectionStringToTuple(c)
contuple = self.connectionStringToTuple(c)
self._graph.addConnection(*contuple)

def save(self):
Expand Down Expand Up @@ -197,7 +207,7 @@ def adaptLibAndFactory(lib, factory):
pass
ncfg["properties"] = p.saveDict()
cfg["nodes"].append(ncfg)
cfg["connections"] = [self._tupleToConnectionString(c) for c in self._graph.allConnections()]
cfg["connections"] = [self.tupleToConnectionString(c) for c in self._graph.allConnections()]
return cfg

@staticmethod
Expand Down
7 changes: 3 additions & 4 deletions nexxT/services/gui/Configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@
import logging
import shiboken2
from PySide2.QtCore import (Qt, QSettings, QByteArray, QDataStream, QIODevice)
from PySide2.QtGui import QPainter
from PySide2.QtWidgets import (QTreeView, QAction, QStyle, QApplication, QFileDialog, QAbstractItemView, QMessageBox,
QHeaderView, QMenu, QDockWidget, QGraphicsView)
QHeaderView, QMenu, QDockWidget)
from nexxT.interface import Services, FilterState
from nexxT.core.Configuration import Configuration
from nexxT.core.Utils import assertMainThread
from nexxT.services.SrvConfiguration import MVCConfigurationBase, ConfigurationModel, ITEM_ROLE
from nexxT.services.gui.PropertyDelegate import PropertyDelegate
from nexxT.services.gui.GraphEditor import GraphScene
from nexxT.services.gui.GraphEditorView import GraphEditorView

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -190,8 +190,7 @@ def _addGraphView(self, subConfig):
allowedArea=Qt.RightDockWidgetArea | Qt.BottomDockWidgetArea)
graphDw.setAttribute(Qt.WA_DeleteOnClose, True)
assert isinstance(graphDw, QDockWidget)
graphView = QGraphicsView(graphDw)
graphView.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform)
graphView = GraphEditorView(graphDw)
graphView.setScene(GraphScene(subConfig.getGraph(), graphDw))
graphDw.setWidget(graphView)
self._graphViews.append(graphDw)
Expand Down
62 changes: 52 additions & 10 deletions nexxT/services/gui/GraphEditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from PySide2.QtWidgets import (QGraphicsScene, QGraphicsItemGroup, QGraphicsSimpleTextItem,
QGraphicsPathItem, QGraphicsItem, QMenu, QAction, QInputDialog, QMessageBox,
QGraphicsLineItem, QFileDialog, QDialog, QGridLayout, QCheckBox, QVBoxLayout, QGroupBox,
QDialogButtonBox)
QDialogButtonBox, QGraphicsView, QStyle, QStyleOptionGraphicsItem)
from PySide2.QtGui import QBrush, QPen, QColor, QPainterPath, QImage
from PySide2.QtCore import QPointF, Signal, QObject, QRectF, QSizeF, Qt
from nexxT.core.BaseGraph import BaseGraph
Expand Down Expand Up @@ -142,6 +142,7 @@ def __init__(self, name):
self.setHandlesChildEvents(False)
self.setFlag(QGraphicsItem.ItemClipsToShape, True)
self.setFlag(QGraphicsItem.ItemClipsChildrenToShape, True)
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
self.hovered = False
self.sync()

Expand Down Expand Up @@ -282,6 +283,29 @@ def hoverLeave(self):
self.setFlag(QGraphicsItem.ItemIsMovable, False)
self.sync()

def itemChange(self, change, value):
"""
overwritten from QGraphicsItem
:param change: the thing that has changed
:param value: the new value
:return:
"""
if change in [QGraphicsItem.ItemSelectedHasChanged]:
self.sync()
return super().itemChange(change, value)

def paint(self, painter, option, widget):
"""
Overwritten from base class to prevent drawing the selection rectangle
:param painter: a QPainter instance
:param option: a QStyleOptionGraphicsItem instance
:param widget: a QWidget instance or None
:return:
"""
no = QStyleOptionGraphicsItem(option)
no.state = no.state & ~QStyle.State_Selected
super().paint(painter, no, widget)

class PortItem:
"""
This class represents a port in a node.
Expand Down Expand Up @@ -492,6 +516,7 @@ def __init__(self, parent):
self.connections = []
self.itemOfContextMenu = None
self.addingConnection = None
self._lastEndPortHovered = None

def addNode(self, name):
"""
Expand Down Expand Up @@ -680,12 +705,13 @@ def getData(item, role):
BaseGraphScene.STYLE_ROLE_PEN : QPen(QColor(10, 10, 10)),
BaseGraphScene.STYLE_ROLE_BRUSH : QBrush(QColor(10, 200, 10, 180)),
}
NODE_STYLE_HOVERED = {
BaseGraphScene.STYLE_ROLE_PEN : QPen(QColor(10, 10, 10), 3),
}
if item.hovered:
return NODE_STYLE_HOVERED.get(role, NODE_STYLE.get(role, DEFAULTS.get(role)))
return NODE_STYLE.get(role, DEFAULTS.get(role))
res = NODE_STYLE.get(role, DEFAULTS.get(role))
if item.hovered and role == BaseGraphScene.STYLE_ROLE_PEN:
res.setWidthF(3)
if item.isSelected() and role == BaseGraphScene.STYLE_ROLE_PEN:
res.setWidthF(max(2, res.widthF()))
res.setStyle(Qt.DashLine)
return res
if isinstance(item, BaseGraphScene.PortItem):
def portIdx(portItem):
nodeItem = portItem.nodeItem
Expand Down Expand Up @@ -755,7 +781,9 @@ def mousePressEvent(self, event):
self.addItem(lineItem)
self.addingConnection = dict(port=item, lineItem=lineItem)
self.update()
return None
for v in self.views():
v.setDragMode(QGraphicsView.NoDrag)
return True
return super().mousePressEvent(event)

def mouseMoveEvent(self, event):
Expand All @@ -769,8 +797,17 @@ def mouseMoveEvent(self, event):
toPos = lineItem.mapFromScene(event.scenePos())
lineItem.prepareGeometryChange()
lineItem.setLine(lineItem.line().x1(), lineItem.line().y1(), toPos.x(), toPos.y())
item = self.graphItemAt(event.scenePos())
if not isinstance(item, BaseGraphScene.PortItem):
item = None
if isinstance(item, BaseGraphScene.PortItem) and item != self._lastEndPortHovered:
self._lastEndPortHovered = item
item.hoverEnter()
elif item != self._lastEndPortHovered and self._lastEndPortHovered is not None:
self._lastEndPortHovered.hoverLeave()
self._lastEndPortHovered = None
self.update()
return None
return True
return super().mouseMoveEvent(event)

def mouseReleaseEvent(self, event):
Expand All @@ -780,6 +817,8 @@ def mouseReleaseEvent(self, event):
:return:
"""
if event.button() == Qt.LeftButton and self.addingConnection is not None:
for v in self.views():
v.setDragMode(QGraphicsView.RubberBandDrag)
portOther = self.addingConnection["port"]
self.removeItem(self.addingConnection["lineItem"])
self.addingConnection = None
Expand All @@ -792,7 +831,10 @@ def mouseReleaseEvent(self, event):
portFrom = portHere
portTo = portOther
self.connectionAddRequest.emit(portFrom.nodeItem.name, portFrom.name, portTo.nodeItem.name, portTo.name)
return None
if self._lastEndPortHovered is not None:
self._lastEndPortHovered.hoverLeave()
self._lastEndPortHovered = None
return True
return super().mouseReleaseEvent(event)

def autoLayout(self):
Expand Down
115 changes: 115 additions & 0 deletions nexxT/services/gui/GraphEditorView.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2020 ifm electronic gmbh
#
# THE PROGRAM IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.
#

"""
This module provides the GraphEditorView class.
"""

import logging
import json
from PySide2.QtCore import QMimeData
from PySide2.QtGui import QPainter, QKeySequence, QGuiApplication
from PySide2.QtWidgets import QGraphicsView
from nexxT.core.SubConfiguration import SubConfiguration
from nexxT.services.gui.GraphEditor import BaseGraphScene, GraphScene

logger = logging.getLogger(__name__)

class GraphEditorView(QGraphicsView):
"""
Subclass of QGraphicsView which handles copy&paste events
"""
def __init__(self, parent):
"""
Constructor
:param parent: a QWidget instance
"""
super().__init__(parent=parent)
self.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform)
self.setDragMode(QGraphicsView.RubberBandDrag)

def keyPressEvent(self, event):
"""
Overwritten from QGraphicsView for intercepting copy & paste events.
:param event: a QKeyEvent instance
:return:
"""
if event.matches(QKeySequence.Copy):
self._copy(cut=False)
return True
if event.matches(QKeySequence.Cut):
self._copy(cut=True)
if event.matches(QKeySequence.Paste):
self._paste()
return True
return super().keyPressEvent(event)

def _copy(self, cut):
"""
Copys the selection to clipboard.
:param cut: boolean whether the copy is actually a cut.
:return:
"""
logger.internal("Copying...")
sc = self.scene()
assert isinstance(sc, GraphScene)
items = sc.selectedItems()
nodes = set()
for i in items:
if isinstance(i, BaseGraphScene.NodeItem):
nodes.add(i.name)
saved = sc.graph.getSubConfig().save()
if "_guiState" in saved:
del saved["_guiState"]
toDelIdx = []
deletedNodes = set()
for i, n in enumerate(saved["nodes"]):
if not n["name"] in nodes:
toDelIdx.append(i)
deletedNodes.add(n["name"])
for i in toDelIdx[::-1]:
saved["nodes"] = saved["nodes"][:i] + saved["nodes"][i+1:]
cToDel = set()
for c in saved["connections"]:
node1, _, node2, _ = SubConfiguration.connectionStringToTuple(c)
if node1 in deletedNodes or node2 in deletedNodes:
cToDel.add(c)
for c in cToDel:
saved["connections"].remove(c)
md = QMimeData()
md.setData("nexxT/json", json.dumps(saved, indent=2, ensure_ascii=False).encode())
QGuiApplication.clipboard().setMimeData(md)
if cut:
for n in saved["nodes"]:
sc.graph.deleteNode(n["name"])
logger.info("Copyied %d nodes and %d connections", len(saved["nodes"]), len(saved["connections"]))

def _paste(self):
"""
Pastes the clipboard contents to the scene.
:return:
"""
md = QGuiApplication.clipboard().mimeData()
ba = md.data("nexxT/json")
if ba.count() > 0:
logger.internal("Paste")
cfg = json.loads(bytes(ba).decode())
nameTransformations = {}
for n in cfg["nodes"]:
nameTransformations[n["name"]] = self.scene().graph.uniqueNodeName(n["name"])
n["name"] = nameTransformations[n["name"]]
newConn = []
for c in cfg["connections"]:
node1, port1, node2, port2 = SubConfiguration.connectionStringToTuple(c)
node1 = nameTransformations[node1]
node2 = nameTransformations[node2]
newConn.append(SubConfiguration.tupleToConnectionString((node1, port1, node2, port2)))
cfg["connections"] = newConn
def compositeLookup(name):
return self.scene().graph.getSubConfig().getConfiguration().compositeFilterByName(name)
self.scene().graph.getSubConfig().load(cfg, compositeLookup)
self.scene().autoLayout()
logger.info("Pasted")

0 comments on commit fcfb3ad

Please sign in to comment.