diff --git a/.github/workflows/cmake_build.yml b/.github/workflows/cmake_build.yml index b513864c..766fc2ef 100644 --- a/.github/workflows/cmake_build.yml +++ b/.github/workflows/cmake_build.yml @@ -30,9 +30,9 @@ jobs: - toolchain: linux-gcc os: ubuntu-22.04 compiler: gcc - qt_version: "5.15.2" + qt_version: "6.3.0" modules: "" - use_qt6: "OFF" + use_qt6: "ON" - toolchain: macos-clang os: macos-latest diff --git a/CMakeLists.txt b/CMakeLists.txt index 51db1f68..e63c2e64 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -87,6 +87,8 @@ set(CPP_SOURCE_FILES src/StyleCollection.cpp src/UndoCommands.cpp src/locateNode.cpp + src/GroupGraphicsObject.cpp + src/NodeGroup.cpp resources/resources.qrc ) @@ -127,6 +129,8 @@ set(HPP_HEADER_FILES include/QtNodes/internal/DefaultVerticalNodeGeometry.hpp include/QtNodes/internal/NodeConnectionInteraction.hpp include/QtNodes/internal/UndoCommands.hpp + include/QtNodes/internal/NodeGroup.hpp + include/QtNodes/internal/GroupGraphicsObject.hpp ) # If we want to give the option to build a static library, diff --git a/examples/calculator/main.cpp b/examples/calculator/main.cpp index 4d2504f0..f5a51179 100644 --- a/examples/calculator/main.cpp +++ b/examples/calculator/main.cpp @@ -112,6 +112,11 @@ int main(int argc, char *argv[]) mainWidget.setWindowModified(true); }); + if (scene->groupingEnabled()) { + auto loadGroupAction = menu->addAction("Load Group..."); + QObject::connect(loadGroupAction, &QAction::triggered, [scene] { scene->loadGroupFile(); }); + } + mainWidget.setWindowTitle("[*]Data Flow: simplest calculator"); mainWidget.resize(800, 600); // Center window. diff --git a/include/QtNodes/GroupGraphicsObject b/include/QtNodes/GroupGraphicsObject new file mode 100644 index 00000000..8461c673 --- /dev/null +++ b/include/QtNodes/GroupGraphicsObject @@ -0,0 +1 @@ +#include "internal/GroupGraphicsObject.hpp" diff --git a/include/QtNodes/NodeGroup b/include/QtNodes/NodeGroup new file mode 100644 index 00000000..2becb8b3 --- /dev/null +++ b/include/QtNodes/NodeGroup @@ -0,0 +1 @@ +#include "internal/NodeGroup.hpp" diff --git a/include/QtNodes/internal/BasicGraphicsScene.hpp b/include/QtNodes/internal/BasicGraphicsScene.hpp index 036bb932..087ccfb6 100644 --- a/include/QtNodes/internal/BasicGraphicsScene.hpp +++ b/include/QtNodes/internal/BasicGraphicsScene.hpp @@ -5,10 +5,11 @@ #include "ConnectionIdHash.hpp" #include "Definitions.hpp" #include "Export.hpp" +#include "GroupGraphicsObject.hpp" +#include "NodeGroup.hpp" +#include "UndoCommands.hpp" -#include "QUuidStdHash.hpp" - -#include +#include #include #include @@ -27,6 +28,11 @@ class AbstractNodePainter; class ConnectionGraphicsObject; class NodeGraphicsObject; class NodeStyle; +class DeleteCommand; +class CopyCommand; +class NodeGroup; +class GroupGraphicsObject; +struct ConnectionId; /// An instance of QGraphicsScene, holds connections and nodes. class NODE_EDITOR_PUBLIC BasicGraphicsScene : public QGraphicsScene @@ -62,6 +68,17 @@ class NODE_EDITOR_PUBLIC BasicGraphicsScene : public QGraphicsScene QUndoStack &undoStack(); + /** + * @brief Setter for the _groupingEnabled flag. + * @param boolean to set or not the flag. + */ + void setGroupingEnabled(bool enabled); + + /** + * @brief Getter for the _groupingEnabled flag. + */ + bool groupingEnabled() const { return _groupingEnabled; } + public: /** * @brief Creates a "draft" instance of ConnectionGraphicsObject. @@ -87,6 +104,83 @@ class NODE_EDITOR_PUBLIC BasicGraphicsScene : public QGraphicsScene /// Deletes all the nodes. Connections are removed automatically. void clearScene(); + /** + * @brief Creates a list of the connections that are incident only to nodes within a + * given group. + * @param groupID ID of the desired group. + * @return List of (pointers of) connections whose both endpoints belong to members of + * the specified group. + */ + std::vector> connectionsWithinGroup(GroupId groupID); + /** + * @brief Creates a group in the scene containing the given nodes. + * @param nodes Reference to the list of nodes to be included in the group. + * @param name Group's name. + * @param groupId Group's id. + * @return Pointer to the newly-created group. + */ + std::weak_ptr createGroup(std::vector &nodes, + QString name = QStringLiteral(""), + GroupId groupId = InvalidGroupId); + + /** + * @brief Creates a group in the scene containing the currently selected nodes. + * @param name Group's name + * @return Pointer to the newly-created group. + */ + std::weak_ptr createGroupFromSelection(QString groupName = QStringLiteral("")); + + /** + * @brief Restores a group from a JSON object. + * @param groupJson JSON object containing the group data. + * @return Pair consisting of a pointer to the newly-created group and the mapping + * between old and new nodes. + */ + std::pair, std::unordered_map> restoreGroup( + QJsonObject const &groupJson); + + /** + * @brief Returns a const reference to the mapping of existing groups. + */ + std::unordered_map> const &groups() const; + + /** + * @brief Loads a group from a file specified by the user. + * @return Pointer to the newly-created group. + */ + std::weak_ptr loadGroupFile(); + + /** + * @brief Saves a group in a .group file. + * @param groupID Group's id. + */ + void saveGroupFile(GroupId groupID); + + /** + * @brief Calculates the selected nodes. + * @return Vector containing the NodeGraphicsObject pointers related to the selected nodes. + */ + std::vector selectedNodes() const; + + /** + * @brief Calculates the selected groups. + * @return Vector containing the GroupGraphicsObject pointers related to the selected groups. + */ + std::vector selectedGroups() const; + + /** + * @brief Adds a node to a group, if both node and group exists. + * @param nodeId Node's id. + * @param groupId Group's id. + */ + void addNodeToGroup(NodeId nodeId, GroupId groupId); + + /** + * @brief Removes a node from a group, if the node exists and is within a group. + * @param nodeId Node's id. + */ + void removeNodeFromGroup(NodeId nodeId); + public: /** * @returns NodeGraphicsObject associated with the given nodeId. @@ -111,6 +205,17 @@ class NODE_EDITOR_PUBLIC BasicGraphicsScene : public QGraphicsScene */ virtual QMenu *createSceneMenu(QPointF const scenePos); + /** + * @brief Creates the default menu when a node is selected. + */ + QMenu *createStdMenu(QPointF const scenePos); + + /** + * @brief Creates the menu when a group is selected. + * @param groupGo reference to the GroupGraphicsObject related to the selected group. + */ + QMenu *createGroupMenu(QPointF const scenePos, GroupGraphicsObject *groupGo); + Q_SIGNALS: void modified(BasicGraphicsScene *); void nodeMoved(NodeId const nodeId, QPointF const &newLocation); @@ -141,6 +246,24 @@ class NODE_EDITOR_PUBLIC BasicGraphicsScene : public QGraphicsScene /// Redraws adjacent nodes for given `connectionId` void updateAttachedNodes(ConnectionId const connectionId, PortType const portType); + /** + * @brief Loads a JSON object that represents a node, with the option + * to keep the stored node id or generate a new one. + * @param nodeJson The JSON object representing a node. + * @param keepOriginalId If true, the loaded node will have the same id as the one stored in + * the file; otherwise, a new id will be generated + * @return A reference to the NodeGraphicsObject related to the loaded node. + */ + NodeGraphicsObject &loadNodeToMap(QJsonObject nodeJson, bool keepOriginalId = false); + + /** + * @brief Loads a connection between nodes from a JSON file. + * @param connectionJson JSON object that stores the connection's endpoints. + * @param nodeIdMap Map of nodes (i.e. all possible endpoints). + */ + void loadConnectionToMap(QJsonObject const &connectionJson, + std::unordered_map const &nodeIdMap); + public Q_SLOTS: /// Slot called when the `connectionId` is erased form the AbstractGraphModel. virtual void onConnectionDeleted(ConnectionId const connectionId); @@ -155,14 +278,29 @@ public Q_SLOTS: virtual void onNodeClicked(NodeId const nodeId); virtual void onModelReset(); + /** + * @brief Slot called to trigger the copy command action. + */ + void onCopySelectedObjects() { undoStack().push(new CopyCommand(this)); } + + /** + * @brief Slot called to trigger the delete command action. + */ + void onDeleteSelectedObjects() { undoStack().push(new DeleteCommand(this)); } + private: AbstractGraphModel &_graphModel; using UniqueNodeGraphicsObject = std::unique_ptr; using UniqueConnectionGraphicsObject = std::unique_ptr; + using SharedGroup = std::shared_ptr; std::unordered_map _nodeGraphicsObjects; std::unordered_map _connectionGraphicsObjects; + GroupId nextGroupId(); + + std::unordered_map _groups{}; + GroupId _nextGroupId{0}; std::unique_ptr _draftConnection; std::unique_ptr _nodeGeometry; std::unique_ptr _nodePainter; @@ -170,6 +308,7 @@ public Q_SLOTS: bool _nodeDrag; QUndoStack *_undoStack; Qt::Orientation _orientation; + bool _groupingEnabled; }; } // namespace QtNodes diff --git a/include/QtNodes/internal/Definitions.hpp b/include/QtNodes/internal/Definitions.hpp index 8c01475f..fc4ffc26 100644 --- a/include/QtNodes/internal/Definitions.hpp +++ b/include/QtNodes/internal/Definitions.hpp @@ -18,23 +18,23 @@ NODE_EDITOR_PUBLIC Q_NAMESPACE Q_NAMESPACE_EXPORT(NODE_EDITOR_PUBLIC) #endif -/** + /** * Constants used for fetching QVariant data from GraphModel. */ -enum class NodeRole { - Type = 0, ///< Type of the current node, usually a string. - Position = 1, ///< `QPointF` positon of the node on the scene. - Size = 2, ///< `QSize` for resizable nodes. - CaptionVisible = 3, ///< `bool` for caption visibility. - Caption = 4, ///< `QString` for node caption. - Style = 5, ///< Custom NodeStyle as QJsonDocument - InternalData = 6, ///< Node-stecific user data as QJsonObject - InPortCount = 7, ///< `unsigned int` - OutPortCount = 9, ///< `unsigned int` - Widget = 10, ///< Optional `QWidget*` or `nullptr` - ValidationState = 11, ///< Enum NodeValidationState of the node - ProcessingStatus = 12 ///< Enum NodeProcessingStatus of the node -}; + enum class NodeRole { + Type = 0, ///< Type of the current node, usually a string. + Position = 1, ///< `QPointF` positon of the node on the scene. + Size = 2, ///< `QSize` for resizable nodes. + CaptionVisible = 3, ///< `bool` for caption visibility. + Caption = 4, ///< `QString` for node caption. + Style = 5, ///< Custom NodeStyle as QJsonDocument + InternalData = 6, ///< Node-stecific user data as QJsonObject + InPortCount = 7, ///< `unsigned int` + OutPortCount = 9, ///< `unsigned int` + Widget = 10, ///< Optional `QWidget*` or `nullptr` + ValidationState = 11, ///< Enum NodeValidationState of the node + ProcessingStatus = 12 ///< Enum NodeProcessingStatus of the node + }; Q_ENUM_NS(NodeRole) /** @@ -94,6 +94,11 @@ using NodeId = unsigned int; static constexpr NodeId InvalidNodeId = std::numeric_limits::max(); +/// Unique Id associated with each node group. +using GroupId = unsigned int; + +static constexpr GroupId InvalidGroupId = std::numeric_limits::max(); + /** * A unique connection identificator that stores * out `NodeId`, out `PortIndex`, in `NodeId`, in `PortIndex` diff --git a/include/QtNodes/internal/GraphicsView.hpp b/include/QtNodes/internal/GraphicsView.hpp index 9a657962..fdc5af7c 100644 --- a/include/QtNodes/internal/GraphicsView.hpp +++ b/include/QtNodes/internal/GraphicsView.hpp @@ -91,6 +91,7 @@ public Q_SLOTS: private: QAction *_clearSelectionAction = nullptr; QAction *_deleteSelectionAction = nullptr; + QAction *_cutSelectionAction = nullptr; QAction *_duplicateSelectionAction = nullptr; QAction *_copySelectionAction = nullptr; QAction *_pasteAction = nullptr; diff --git a/include/QtNodes/internal/GroupGraphicsObject.hpp b/include/QtNodes/internal/GroupGraphicsObject.hpp new file mode 100644 index 00000000..1b635fbd --- /dev/null +++ b/include/QtNodes/internal/GroupGraphicsObject.hpp @@ -0,0 +1,256 @@ +#pragma once + +#include "BasicGraphicsScene.hpp" +#include "Definitions.hpp" +#include "NodeGroup.hpp" +#include +#include + +/** + * @brief The IconGraphicsItem class is an auxiliary class that implements + * custom behaviour to a fixed-size icon object. + */ +class IconGraphicsItem : public QGraphicsPixmapItem +{ +public: + IconGraphicsItem(QGraphicsItem *parent = nullptr); + + IconGraphicsItem(const QPixmap &pixmap, QGraphicsItem *parent = nullptr); + + /** + * @brief Returns the factor by which the original image was scaled + * to fit the desired icon size. + */ + double scaleFactor() const; + + /** + * @brief Returns the icon size. + */ + static constexpr double iconSize() { return _iconSize; } + +private: + double _scaleFactor{}; + +private: + static constexpr double _iconSize = 24.0; +}; + +namespace QtNodes { + +class BasicGraphicsScene; +class NodeGroup; +class NodeGraphicsObject; + +/** + * @brief The GroupGraphicsObject class handles the graphical part of a node group. + * Each node group is associated with a unique GroupGraphicsObject. + */ +class GroupGraphicsObject + : public QObject + , public QGraphicsRectItem +{ + Q_OBJECT + +public: + /** + * @brief Constructor that creates a group's graphical object that should be + * included in the given scene and associated with the given NodeGroup object. + * @param scene Reference to the scene that will include this object. + * @param nodeGroup Reference to the group associated with this object. + */ + GroupGraphicsObject(BasicGraphicsScene &scene, NodeGroup &nodeGroup); + + GroupGraphicsObject(const GroupGraphicsObject &ggo) = delete; + GroupGraphicsObject &operator=(const GroupGraphicsObject &other) = delete; + GroupGraphicsObject(GroupGraphicsObject &&ggo) = delete; + GroupGraphicsObject &operator=(GroupGraphicsObject &&other) = delete; + + ~GroupGraphicsObject() override; + + /** + * @brief Returns a reference to this object's associated group. + */ + NodeGroup &group(); + + /** + * @brief Returns a const reference to this object's associated group. + */ + NodeGroup const &group() const; + + /** + * @copydoc QGraphicsItem::boundingRect() + */ + QRectF boundingRect() const override; + + enum { Type = UserType + 3 }; + + /** + * @copydoc QGraphicsItem::type() + */ + int type() const override { return Type; } + + /** + * @brief Sets the group's area color. + * @param color Color to paint the group area. + */ + void setFillColor(const QColor &color); + + /** + * @brief Sets the group's border color. + * @param color Color to paint the group's border. + */ + void setBorderColor(const QColor &color); + + /** + * @brief Updates the position of all the connections that are incident + * to the nodes of this group. + */ + void moveConnections(); + + /** + * @brief Moves the position of all the nodes of this group by the amount given. + * @param offset 2D vector representing the amount by which the group has moved. + */ + void moveNodes(const QPointF &offset); + + /** + * @brief Sets the lock state of the group. Locked groups don't allow individual + * interactions with its nodes, and can only be moved or selected as a whole. + * @param locked Determines whether this group should be locked. + */ + void lock(bool locked); + + /** + * @brief Returns the lock state of the group. Locked groups don't allow individual + * interactions with its nodes, and can only be moved or selected as a whole. + */ + bool locked() const; + + /** + * @brief Updates the position of the group's padlock icon to + * the top-right corner. + */ + void positionLockedIcon(); + + /** + * @brief Sets the group hovered state. When the mouse pointer hovers over + * (or leaves) a group, the group's appearance changes. + * @param hovered Determines the hovered state. + */ + void setHovered(bool hovered); + + /** + * @brief When a node is dragged within the borders of a group, the group's + * area expands to include the node until the node leaves the area or is + * released in the group. This function temporarily sets the node as the + * possible newest member of the group, making the group's area expand. + * @param possibleChild Pointer to the node that may be included. + */ + void setPossibleChild(NodeGraphicsObject *possibleChild); + + /** + * @brief Clears the possibleChild variable. + * @note See setPossibleChild(NodeGraphicsObject*). + */ + void unsetPossibleChild(); + + /** + * @brief Returns all the connections that are incident strictly within the + * nodes of this group. + */ + std::vector> connections() const; + + /** + * @brief Sets the position of the group. + * @param position The desired (top-left corner) position of the group, in + * scene coordinates. + */ + void setPosition(const QPointF &position); + +protected: + /** @copydoc QGraphicsItem::paint() */ + void paint(QPainter *painter, + QStyleOptionGraphicsItem const *option, + QWidget *widget = nullptr) override; + + /** @copydoc QGraphicsItem::hoverEnterEvent() */ + void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override; + + /** @copydoc QGraphicsItem::hoverLeaveEvent() */ + void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override; + + /** @copydoc QGraphicsItem::mouseMoveEvent() */ + void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; + + /** @copydoc QGraphicsItem::mouseDoubleClickEvent() */ + void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override; + +public: + /** + * @brief _currentFillColor Holds the current color of the group background. + */ + QColor _currentFillColor; + + /** + * @brief _currentBorderColor Holds the current color of the group border. + */ + QColor _currentBorderColor; + + const QColor kUnlockedFillColor = QColor("#20a5b084"); + const QColor kUnlockedHoverColor = QColor("#2083a4af"); + + const QColor kLockedFillColor = QColor("#3fe0bebc"); + const QColor kLockedHoverColor = QColor("#3feecdcb"); + + const QColor kSelectedBorderColor = QColor("#eeffa500"); + const QColor kUnselectedBorderColor = QColor("#eeaaaaaa"); + + /** + * @brief _borderPen Object that dictates how the group border should be drawn. + */ + QPen _borderPen; + +private: + /** + * @brief _scene Reference to the scene object in which this object is included. + */ + BasicGraphicsScene &_scene; + + /** + * @brief _group Reference to the group instance that corresponds to this object. + */ + NodeGroup &_group; + + IconGraphicsItem *_lockedGraphicsItem; + IconGraphicsItem *_unlockedGraphicsItem; + + QPixmap _lockedIcon{QStringLiteral("://padlock-lock.png")}; + QPixmap _unlockedIcon{QStringLiteral("://padlock-unlock.png")}; + + /** + * @brief _possibleChild Pointer that temporarily is set to an existing node when + * the user drags the node to this group's area. + */ + NodeGraphicsObject *_possibleChild; + + /** + * @brief _locked Holds the lock state of the group. Locked groups don't allow individual + * interactions with its nodes, and can only be moved or selected as a whole. + */ + bool _locked; + + static constexpr double _groupBorderX = 25.0; + static constexpr double _groupBorderY = _groupBorderX * 0.8; + static constexpr double _roundedBorderRadius = _groupBorderY; + static constexpr QMarginsF _margins = QMarginsF(_groupBorderX, + _groupBorderY + IconGraphicsItem::iconSize(), + _groupBorderX + IconGraphicsItem::iconSize(), + _groupBorderY); + + static constexpr double _defaultWidth = 50.0; + static constexpr double _defaultHeight = 50.0; + + static constexpr double _groupAreaZValue = 2.0; +}; + +} // namespace QtNodes diff --git a/include/QtNodes/internal/NodeConnectionInteraction.hpp b/include/QtNodes/internal/NodeConnectionInteraction.hpp index b22f3c7a..f71ae97e 100644 --- a/include/QtNodes/internal/NodeConnectionInteraction.hpp +++ b/include/QtNodes/internal/NodeConnectionInteraction.hpp @@ -1,7 +1,7 @@ #pragma once #include "Definitions.hpp" - +#include "NodeGraphicsObject.hpp" #include namespace QtNodes { @@ -53,6 +53,11 @@ class NodeConnectionInteraction */ bool disconnect(PortType portToDisconnect) const; + /** + * @brief Getter for the NodeGraphicsObject object. + */ + NodeGraphicsObject &nodeGraphicsObject() { return _ngo; } + private: PortType connectionRequiredPort() const; diff --git a/include/QtNodes/internal/NodeDelegateModel.hpp b/include/QtNodes/internal/NodeDelegateModel.hpp index 04cc78a1..3d3778f8 100644 --- a/include/QtNodes/internal/NodeDelegateModel.hpp +++ b/include/QtNodes/internal/NodeDelegateModel.hpp @@ -65,15 +65,15 @@ class NODE_EDITOR_PUBLIC NodeDelegateModel virtual ~NodeDelegateModel() = default; - /// It is possible to hide caption in GUI - virtual bool captionVisible() const { return true; } - /// Name makes this model unique virtual QString name() const = 0; /// Caption is used in GUI virtual QString caption() const = 0; + /// It is possible to hide caption in GUI + virtual bool captionVisible() const { return true; } + /// Port caption is used in GUI to label individual ports virtual QString portCaption(PortType, PortIndex) const { return QString(); } @@ -90,10 +90,11 @@ class NODE_EDITOR_PUBLIC NodeDelegateModel void load(QJsonObject const &) override; - void setValidationState(const NodeValidationState &validationState); - void setNodeProcessingStatus(NodeProcessingStatus status); + void setValidationState(const NodeValidationState &validationState); + +public: virtual unsigned int nPorts(PortType portType) const = 0; virtual NodeDataType dataType(PortType portType, PortIndex portIndex) const = 0; @@ -146,7 +147,6 @@ public Q_SLOTS: void dataInvalidated(PortIndex const index); void computingStarted(); - void computingFinished(); void embeddedWidgetSizeUpdated(); @@ -160,6 +160,7 @@ public Q_SLOTS: void requestNodeUpdate(); /// Call this function before deleting the data associated with ports. + /** * @brief Call this function before deleting the data associated with ports. * The function notifies the Graph Model and makes it remove and recompute the diff --git a/include/QtNodes/internal/NodeGraphicsObject.hpp b/include/QtNodes/internal/NodeGraphicsObject.hpp index b3c01b90..b9c3c57b 100644 --- a/include/QtNodes/internal/NodeGraphicsObject.hpp +++ b/include/QtNodes/internal/NodeGraphicsObject.hpp @@ -1,6 +1,10 @@ #pragma once +#include "NodeDelegateModel.hpp" +#include "NodeGroup.hpp" +#include "NodeState.hpp" #include +#include #include #include @@ -12,6 +16,9 @@ namespace QtNodes { class BasicGraphicsScene; class AbstractGraphModel; +class NodeGroup; +class NodeDelegateModel; +class GroupGraphicsObject; class NodeGraphicsObject : public QGraphicsObject { @@ -51,8 +58,25 @@ class NodeGraphicsObject : public QGraphicsObject /// Repaints the node once with reacting ports. void reactToConnection(ConnectionGraphicsObject const *cgo); + /// Lockes/unlockes nodes in a selected node group. + void lock(bool locked); + void updateQWidgetEmbedPos(); + /// Saves node in a QJsonObject save file. + QJsonObject save() const; + + /** @brief Setter for the NodeGroup object. + * @param shared pointer to the node group. + */ + void setNodeGroup(std::shared_ptr group); + + /// Unsets NodeGroup, setting it to an empty pointer. + void unsetNodeGroup() { _nodeGroup = std::weak_ptr(); } + + /// Getter for the NodeGroup object. + std::weak_ptr nodeGroup() const { return _nodeGroup; } + protected: void paint(QPainter *painter, QStyleOptionGraphicsItem const *option, @@ -80,7 +104,15 @@ class NodeGraphicsObject : public QGraphicsObject NodeState _nodeState; + bool _locked; + + bool _draggingIntoGroup; + GroupGraphicsObject *_possibleGroup; + QRectF _originalGroupSize; + // either nullptr or owned by parent QGraphicsItem QGraphicsProxyWidget *_proxyWidget; + + std::weak_ptr _nodeGroup{}; }; } // namespace QtNodes diff --git a/include/QtNodes/internal/NodeGroup.hpp b/include/QtNodes/internal/NodeGroup.hpp new file mode 100644 index 00000000..f8accc0a --- /dev/null +++ b/include/QtNodes/internal/NodeGroup.hpp @@ -0,0 +1,144 @@ +#pragma once + +#include +#include +#include + +#include "DataFlowGraphModel.hpp" +#include "Definitions.hpp" +#include "Export.hpp" +#include "GroupGraphicsObject.hpp" +#include "NodeConnectionInteraction.hpp" +#include "NodeState.hpp" + +namespace QtNodes { + +class DataFlowGraphModel; +class GroupGraphicsObject; +class NodeState; +class NodeConnectionInteraction; +class NodeGraphicsObject; +struct ConnectionId; + +/** + * @brief The NodeGroup class defines a controller for node groups. It is + * responsible for managing the group's data and the interactions with other + * classes. + */ +class NODE_EDITOR_PUBLIC NodeGroup : public QObject +{ + Q_OBJECT + +public: + /** + * @brief Constructor to create a node group with the given nodes. + * @param nodes List of node pointers to be included in the group. + * @param groupId Group's identifier. + * @param name Group's name. If none is given, it is automatically generated. + * @param parent Parent object. + */ + NodeGroup(std::vector nodes, + GroupId groupId, + QString name = QString(), + QObject *parent = nullptr); + +public: + /** + * @brief Prepares a byte array containing this group's data to be saved in a + * file. + * @return A byte array containing this group's data (in JSON format). + */ + QByteArray saveToFile() const; + + /** + * @brief Returns this group's identifier. + */ + GroupId id() const; + + /** + * @brief Returns a reference to this group's graphical object. + */ + GroupGraphicsObject &groupGraphicsObject(); + + /** + * @brief Returns a const reference to this group's graphical object. + */ + GroupGraphicsObject const &groupGraphicsObject() const; + + /** + * @brief Returns the list of nodes that belong to this group. + */ + std::vector &childNodes(); + + /** + * @brief Returns a list of IDs of the nodes that belong to this group. + */ + std::vector nodeIDs() const; + + /** + * @brief Returns this group's name. + */ + QString const &name() const; + + /** + * @brief Associates a GroupGraphicsObject with this group. + */ + void setGraphicsObject(std::unique_ptr &&graphics_object); + + /** + * @brief Returns whether the group's list of nodes is empty. + */ + bool empty() const; + + /** + * @brief Returns the number of groups created during the program's execution. + * Used when automatically naming groups. + */ + static int groupCount(); + +public Q_SLOTS: + /** + * @brief Adds a node to this group. + * @param node Pointer to the node to be added. + */ + void addNode(QtNodes::NodeGraphicsObject *node); + + /** + * @brief Removes a node from this group. + * @param node Pointer to the node to be removed. + */ + void removeNode(QtNodes::NodeGraphicsObject *node); + +private: + /** + * @brief Group's name, just a label so the user can easily identify and + * label groups. It is not a unique identifier for the group. + */ + QString _name; + + // addressing + /** + * @brief Identifier of this group. It is the only unique identifier of + * the group. + */ + GroupId _id; + + // data + /** + * @brief List of pointers of nodes that belong to this group. + */ + std::vector _childNodes; + + // painting + /** + * @brief Pointer to the graphical object associated with this group. + */ + std::unique_ptr _groupGraphicsObject; + + /** + * @brief Static variable to count the number of instances of groups that + * were created during execution. Used when automatically naming groups. + */ + static int _groupCount; +}; +} // namespace QtNodes diff --git a/include/QtNodes/internal/NodeState.hpp b/include/QtNodes/internal/NodeState.hpp index 18940214..82394430 100644 --- a/include/QtNodes/internal/NodeState.hpp +++ b/include/QtNodes/internal/NodeState.hpp @@ -7,9 +7,8 @@ #include #include -#include "Export.hpp" - #include "Definitions.hpp" +#include "Export.hpp" #include "NodeData.hpp" namespace QtNodes { diff --git a/resources/padlock-lock.png b/resources/padlock-lock.png new file mode 100644 index 00000000..da36c257 Binary files /dev/null and b/resources/padlock-lock.png differ diff --git a/resources/padlock-unlock.png b/resources/padlock-unlock.png new file mode 100644 index 00000000..0fd62359 Binary files /dev/null and b/resources/padlock-unlock.png differ diff --git a/resources/resources.qrc b/resources/resources.qrc index f9da26fc..370c4206 100644 --- a/resources/resources.qrc +++ b/resources/resources.qrc @@ -7,6 +7,8 @@ status_icons/partial.svg status_icons/pending.svg status_icons/processing.svg - status_icons/updated.svg + status_icons/updated.svg + padlock-lock.png + padlock-unlock.png diff --git a/src/BasicGraphicsScene.cpp b/src/BasicGraphicsScene.cpp index 779dad8f..f33e4c84 100644 --- a/src/BasicGraphicsScene.cpp +++ b/src/BasicGraphicsScene.cpp @@ -7,6 +7,7 @@ #include "DefaultHorizontalNodeGeometry.hpp" #include "DefaultNodePainter.hpp" #include "DefaultVerticalNodeGeometry.hpp" +#include "GraphicsView.hpp" #include "NodeGraphicsObject.hpp" #include @@ -22,9 +23,12 @@ #include #include #include +#include #include #include #include +#include +#include #include #include @@ -33,6 +37,45 @@ #include #include +namespace { + +using QtNodes::GroupId; +using QtNodes::InvalidGroupId; +using QtNodes::InvalidNodeId; +using QtNodes::NodeId; + +NodeId jsonValueToNodeId(QJsonValue const &value) +{ + if (value.isDouble()) { + return static_cast(value.toInt()); + } + + if (value.isString()) { + auto const textValue = value.toString(); + + bool ok = false; + auto const numericValue = textValue.toULongLong(&ok, 10); + if (ok) { + return static_cast(numericValue); + } + + QUuid uuidValue(textValue); + if (!uuidValue.isNull()) { + auto const bytes = uuidValue.toRfc4122(); + if (bytes.size() >= static_cast(sizeof(quint32))) { + QDataStream stream(bytes); + quint32 value32 = 0U; + stream >> value32; + return static_cast(value32); + } + } + } + + return InvalidNodeId; +} + +} // namespace + namespace QtNodes { BasicGraphicsScene::BasicGraphicsScene(AbstractGraphModel &graphModel, QObject *parent) @@ -44,6 +87,7 @@ BasicGraphicsScene::BasicGraphicsScene(AbstractGraphModel &graphModel, QObject * , _nodeDrag(false) , _undoStack(new QUndoStack(this)) , _orientation(Qt::Horizontal) + , _groupingEnabled(true) { setItemIndexMethod(QGraphicsScene::NoIndex); @@ -136,6 +180,33 @@ QUndoStack &BasicGraphicsScene::undoStack() return *_undoStack; } +void BasicGraphicsScene::setGroupingEnabled(bool enabled) +{ + if (_groupingEnabled == enabled) + return; + + if (!enabled) { + for (auto &groupEntry : _groups) { + auto &group = groupEntry.second; + if (!group) + continue; + + for (auto *node : group->childNodes()) { + if (!node) + continue; + + node->unsetNodeGroup(); + node->lock(false); + } + } + + _groups.clear(); + _nextGroupId = 0; + } + + _groupingEnabled = enabled; +} + std::unique_ptr const &BasicGraphicsScene::makeDraftConnection( ConnectionId const incompleteConnectionId) { @@ -160,6 +231,28 @@ void BasicGraphicsScene::clearScene() } } +std::vector> BasicGraphicsScene::connectionsWithinGroup(GroupId groupID) +{ + if (!_groupingEnabled) + return {}; + + std::vector> ret{}; + + for (auto const &connection : _connectionGraphicsObjects) { + auto outNode = nodeGraphicsObject(connection.first.outNodeId); + auto inNode = nodeGraphicsObject(connection.first.inNodeId); + if (outNode && inNode) { + auto group1 = outNode->nodeGroup().lock(); + auto group2 = inNode->nodeGroup().lock(); + if (group1 && group2 && group1->id() == group2->id() && group1->id() == groupID) { + ret.push_back(std::make_shared(connection.first)); + } + } + } + + return ret; +} + NodeGraphicsObject *BasicGraphicsScene::nodeGraphicsObject(NodeId nodeId) { NodeGraphicsObject *ngo = nullptr; @@ -274,6 +367,7 @@ void BasicGraphicsScene::onNodeDeleted(NodeId const nodeId) { auto it = _nodeGraphicsObjects.find(nodeId); if (it != _nodeGraphicsObjects.end()) { + removeNodeFromGroup(nodeId); _nodeGraphicsObjects.erase(it); Q_EMIT modified(this); @@ -331,4 +425,383 @@ void BasicGraphicsScene::onModelReset() traverseGraphAndPopulateGraphicsObjects(); } +std::weak_ptr BasicGraphicsScene::createGroup(std::vector &nodes, + QString groupName, + GroupId groupId) +{ + if (!_groupingEnabled) + return std::weak_ptr(); + + if (nodes.empty()) + return std::weak_ptr(); + + for (auto *node : nodes) { + if (!node->nodeGroup().expired()) + removeNodeFromGroup(node->nodeId()); + } + + if (groupName.isEmpty()) { + groupName = "Group " + QString::number(NodeGroup::groupCount()); + } + + if (groupId == InvalidGroupId) { + groupId = nextGroupId(); + } else { + if (_groups.count(groupId) != 0) { + throw std::runtime_error("Group identifier collision"); + } + + if (groupId >= _nextGroupId && _nextGroupId != InvalidGroupId) { + _nextGroupId = groupId + 1; + } + } + + auto group = std::make_shared(nodes, groupId, groupName, this); + auto ggo = std::make_unique(*this, *group); + + group->setGraphicsObject(std::move(ggo)); + + for (auto &nodePtr : nodes) { + auto node = _nodeGraphicsObjects[nodePtr->nodeId()].get(); + + node->setNodeGroup(group); + } + + std::weak_ptr groupWeakPtr = group; + + _groups[group->id()] = std::move(group); + + return groupWeakPtr; +} + +std::vector BasicGraphicsScene::selectedNodes() const +{ + QList graphicsItems = selectedItems(); + + std::vector result; + result.reserve(graphicsItems.size()); + + for (QGraphicsItem *item : graphicsItems) { + auto ngo = qgraphicsitem_cast(item); + + if (ngo) { + result.push_back(ngo); + } + } + + return result; +} + +std::vector BasicGraphicsScene::selectedGroups() const +{ + if (!_groupingEnabled) + return {}; + + QList graphicsItems = selectedItems(); + + std::vector result; + result.reserve(graphicsItems.size()); + + for (QGraphicsItem *item : graphicsItems) { + auto ngo = qgraphicsitem_cast(item); + + if (ngo) { + result.push_back(ngo); + } + } + + return result; +} + +void BasicGraphicsScene::addNodeToGroup(NodeId nodeId, GroupId groupId) +{ + if (!_groupingEnabled) + return; + + auto groupIt = _groups.find(groupId); + auto nodeIt = _nodeGraphicsObjects.find(nodeId); + if (groupIt == _groups.end() || nodeIt == _nodeGraphicsObjects.end()) + return; + + auto group = groupIt->second; + auto node = nodeIt->second.get(); + group->addNode(node); + node->setNodeGroup(group); +} + +void BasicGraphicsScene::removeNodeFromGroup(NodeId nodeId) +{ + if (!_groupingEnabled) + return; + + auto nodeIt = _nodeGraphicsObjects.find(nodeId); + if (nodeIt == _nodeGraphicsObjects.end()) + return; + + auto group = nodeIt->second->nodeGroup().lock(); + if (group) { + group->removeNode(nodeIt->second.get()); + if (group->empty()) { + _groups.erase(group->id()); + } + } + nodeIt->second->unsetNodeGroup(); + nodeIt->second->lock(false); +} + +std::weak_ptr BasicGraphicsScene::createGroupFromSelection(QString groupName) +{ + if (!_groupingEnabled) + return std::weak_ptr(); + + auto nodes = selectedNodes(); + return createGroup(nodes, groupName); +} + +NodeGraphicsObject &BasicGraphicsScene::loadNodeToMap(QJsonObject nodeJson, bool keepOriginalId) +{ + NodeId newNodeId = InvalidNodeId; + + if (keepOriginalId) { + newNodeId = jsonValueToNodeId(nodeJson["id"]); + } + + if (newNodeId == InvalidNodeId) { + newNodeId = _graphModel.newNodeId(); + nodeJson["id"] = static_cast(newNodeId); + } + + _graphModel.loadNode(nodeJson); + + auto *nodeObject = nodeGraphicsObject(newNodeId); + if (!nodeObject) { + auto graphicsObject = std::make_unique(*this, newNodeId); + nodeObject = graphicsObject.get(); + _nodeGraphicsObjects[newNodeId] = std::move(graphicsObject); + } + + return *nodeObject; +} + +void BasicGraphicsScene::loadConnectionToMap(QJsonObject const &connectionJson, + std::unordered_map const &nodeIdMap) +{ + ConnectionId connId = fromJson(connectionJson); + + auto const outIt = nodeIdMap.find(connId.outNodeId); + auto const inIt = nodeIdMap.find(connId.inNodeId); + + if (outIt == nodeIdMap.end() || inIt == nodeIdMap.end()) { + return; + } + + ConnectionId remapped{outIt->second, connId.outPortIndex, inIt->second, connId.inPortIndex}; + + if (_graphModel.connectionExists(remapped)) { + return; + } + + if (_graphModel.connectionPossible(remapped)) { + _graphModel.addConnection(remapped); + } +} + +std::pair, std::unordered_map> +BasicGraphicsScene::restoreGroup(QJsonObject const &groupJson) +{ + if (!_groupingEnabled) + return {std::weak_ptr(), {}}; + + // since the new nodes will have the same IDs as in the file and the connections + // need these old IDs to be restored, we must create new IDs and map them to the + // old ones so the connections are properly restored + std::unordered_map IDsMap{}; + std::unordered_map nodeIdMap{}; + + std::vector group_children{}; + + QJsonArray nodesJson = groupJson["nodes"].toArray(); + for (const QJsonValueRef nodeJson : nodesJson) { + QJsonObject nodeObject = nodeJson.toObject(); + NodeId const oldNodeId = jsonValueToNodeId(nodeObject["id"]); + + NodeGraphicsObject &nodeRef = loadNodeToMap(nodeObject, false); + NodeId const newNodeId = nodeRef.nodeId(); + + if (oldNodeId != InvalidNodeId) { + nodeIdMap.emplace(oldNodeId, newNodeId); + IDsMap.emplace(static_cast(oldNodeId), static_cast(newNodeId)); + } + + group_children.push_back(&nodeRef); + } + + QJsonArray connectionJsonArray = groupJson["connections"].toArray(); + for (auto connection : connectionJsonArray) { + loadConnectionToMap(connection.toObject(), nodeIdMap); + } + + return std::make_pair(createGroup(group_children, groupJson["name"].toString()), IDsMap); +} + +std::unordered_map> const &BasicGraphicsScene::groups() const +{ + return _groups; +} + +QMenu *BasicGraphicsScene::createStdMenu(QPointF const scenePos) +{ + Q_UNUSED(scenePos); + QMenu *menu = new QMenu(); + + if (_groupingEnabled) { + QMenu *addToGroupMenu = menu->addMenu("Add to group..."); + + for (const auto &groupMap : _groups) { + auto groupPtr = groupMap.second; + auto id = groupMap.first; + + if (!groupPtr) + continue; + + auto groupName = groupPtr->name(); + + QAction *groupAction = addToGroupMenu->addAction(groupName); + + for (const auto &node : selectedNodes()) { + connect(groupAction, &QAction::triggered, [this, id, node]() { + this->addNodeToGroup(node->nodeId(), id); + }); + } + } + + QAction *createGroupAction = menu->addAction("Create group from selection"); + connect(createGroupAction, &QAction::triggered, [this]() { createGroupFromSelection(); }); + } + + QAction *copyAction = menu->addAction("Copy"); + copyAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_C)); + + QAction *cutAction = menu->addAction("Cut"); + cutAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_X)); + + connect(copyAction, &QAction::triggered, this, &BasicGraphicsScene::onCopySelectedObjects); + + connect(cutAction, &QAction::triggered, [this] { + onCopySelectedObjects(); + onDeleteSelectedObjects(); + }); + + menu->setAttribute(Qt::WA_DeleteOnClose); + return menu; +} + +QMenu *BasicGraphicsScene::createGroupMenu(QPointF const scenePos, GroupGraphicsObject *groupGo) +{ + Q_UNUSED(scenePos); + QMenu *menu = new QMenu(); + + QAction *saveGroup = nullptr; + if (_groupingEnabled) { + saveGroup = menu->addAction("Save group..."); + } + + QAction *copyAction = menu->addAction("Copy"); + copyAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_C)); + + QAction *cutAction = menu->addAction("Cut"); + cutAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_X)); + + if (saveGroup) { + connect(saveGroup, &QAction::triggered, [this, groupGo] { + saveGroupFile(groupGo->group().id()); + }); + } + + connect(copyAction, &QAction::triggered, this, &BasicGraphicsScene::onCopySelectedObjects); + + connect(cutAction, &QAction::triggered, [this] { + onCopySelectedObjects(); + onDeleteSelectedObjects(); + }); + + menu->setAttribute(Qt::WA_DeleteOnClose); + return menu; +} + +void BasicGraphicsScene::saveGroupFile(GroupId groupID) +{ + if (!_groupingEnabled) + return; + + QString fileName = QFileDialog::getSaveFileName(nullptr, + tr("Save Node Group"), + QDir::homePath(), + tr("Node Group files (*.group)")); + + if (!fileName.isEmpty()) { + if (!fileName.endsWith("group", Qt::CaseInsensitive)) + fileName += ".group"; + + if (auto groupIt = _groups.find(groupID); groupIt != _groups.end()) { + QFile file(fileName); + if (file.open(QIODevice::WriteOnly)) { + file.write(groupIt->second->saveToFile()); + } else { + qDebug() << "Error saving group file!"; + } + } else { + qDebug() << "Error! Couldn't find group while saving."; + } + } +} + +std::weak_ptr BasicGraphicsScene::loadGroupFile() +{ + if (!_groupingEnabled) + return std::weak_ptr(); + + QString fileName = QFileDialog::getOpenFileName(nullptr, + tr("Open Node Group"), + QDir::currentPath(), + tr("Node Group files (*.group)")); + + if (!QFileInfo::exists(fileName)) + return std::weak_ptr(); + + QFile file(fileName); + + if (!file.open(QIODevice::ReadOnly)) { + qDebug() << "Error loading group file!"; + } + + QDir d = QFileInfo(fileName).absoluteDir(); + QString absolute = d.absolutePath(); + QDir::setCurrent(absolute); + + QByteArray wholeFile = file.readAll(); + + const QJsonObject fileJson = QJsonDocument::fromJson(wholeFile).object(); + + return restoreGroup(fileJson).first; +} + +GroupId BasicGraphicsScene::nextGroupId() +{ + if (_nextGroupId == InvalidGroupId) { + throw std::runtime_error("No available group identifiers"); + } + + while (_groups.count(_nextGroupId) != 0) { + ++_nextGroupId; + if (_nextGroupId == InvalidGroupId) { + throw std::runtime_error("No available group identifiers"); + } + } + + GroupId const newId = _nextGroupId; + ++_nextGroupId; + return newId; +} + } // namespace QtNodes diff --git a/src/DataFlowGraphModel.cpp b/src/DataFlowGraphModel.cpp index d84add7f..263b4388 100644 --- a/src/DataFlowGraphModel.cpp +++ b/src/DataFlowGraphModel.cpp @@ -586,6 +586,7 @@ void DataFlowGraphModel::loadNode(QJsonObject const &nodeJson) &NodeDelegateModel::portsInserted, this, &DataFlowGraphModel::portsInserted); + connect(model.get(), &NodeDelegateModel::requestNodeUpdate, this, [restoredNodeId, this]() { Q_EMIT nodeUpdated(restoredNodeId); }); diff --git a/src/DataFlowGraphicsScene.cpp b/src/DataFlowGraphicsScene.cpp index 32970608..0a028cc1 100644 --- a/src/DataFlowGraphicsScene.cpp +++ b/src/DataFlowGraphicsScene.cpp @@ -21,11 +21,51 @@ #include #include #include +#include +#include +#include #include #include #include +#include +namespace { + +using QtNodes::GroupId; +using QtNodes::InvalidGroupId; + +GroupId jsonValueToGroupId(QJsonValue const &value) +{ + if (value.isDouble()) { + return static_cast(value.toInt()); + } + + if (value.isString()) { + auto const textValue = value.toString(); + + bool ok = false; + auto const numericValue = textValue.toULongLong(&ok, 10); + if (ok) { + return static_cast(numericValue); + } + + QUuid uuidValue(textValue); + if (!uuidValue.isNull()) { + auto const bytes = uuidValue.toRfc4122(); + if (bytes.size() >= static_cast(sizeof(quint32))) { + QDataStream stream(bytes); + quint32 value32 = 0U; + stream >> value32; + return static_cast(value32); + } + } + } + + return InvalidGroupId; +} + +} // namespace namespace QtNodes { @@ -158,7 +198,32 @@ bool DataFlowGraphicsScene::save() const QFile file(fileName); if (file.open(QIODevice::WriteOnly)) { - file.write(QJsonDocument(_graphModel.save()).toJson()); + QJsonObject sceneJson = _graphModel.save(); + + QJsonArray groupsJsonArray; + for (auto const &[groupId, groupPtr] : groups()) { + if (!groupPtr) + continue; + + QJsonObject groupJson; + groupJson["id"] = static_cast(groupId); + groupJson["name"] = groupPtr->name(); + + QJsonArray nodeIdsJson; + for (NodeId const nodeId : groupPtr->nodeIDs()) { + nodeIdsJson.append(static_cast(nodeId)); + } + groupJson["nodes"] = nodeIdsJson; + groupJson["locked"] = groupPtr->groupGraphicsObject().locked(); + + groupsJsonArray.append(groupJson); + } + + if (!groupsJsonArray.isEmpty()) { + sceneJson["groups"] = groupsJsonArray; + } + + file.write(QJsonDocument(sceneJson).toJson()); return true; } } @@ -184,7 +249,45 @@ bool DataFlowGraphicsScene::load() QByteArray const wholeFile = file.readAll(); - _graphModel.load(QJsonDocument::fromJson(wholeFile).object()); + QJsonParseError parseError{}; + QJsonDocument const sceneDocument = QJsonDocument::fromJson(wholeFile, &parseError); + if (parseError.error != QJsonParseError::NoError || !sceneDocument.isObject()) + return false; + + QJsonObject const sceneJson = sceneDocument.object(); + + _graphModel.load(sceneJson); + + if (sceneJson.contains("groups")) { + QJsonArray const groupsJsonArray = sceneJson["groups"].toArray(); + + for (QJsonValue groupValue : groupsJsonArray) { + QJsonObject const groupObject = groupValue.toObject(); + + QJsonArray const nodeIdsJson = groupObject["nodes"].toArray(); + std::vector groupNodes; + groupNodes.reserve(nodeIdsJson.size()); + + for (QJsonValue idValue : nodeIdsJson) { + NodeId const nodeId = static_cast(idValue.toInt()); + if (auto *nodeObject = nodeGraphicsObject(nodeId)) { + groupNodes.push_back(nodeObject); + } + } + + if (groupNodes.empty()) + continue; + + QString const groupName = groupObject["name"].toString(); + GroupId const groupId = jsonValueToGroupId(groupObject["id"]); + + auto const groupWeak = createGroup(groupNodes, groupName, groupId); + if (auto group = groupWeak.lock()) { + bool const locked = groupObject["locked"].toBool(true); + group->groupGraphicsObject().lock(locked); + } + } + } Q_EMIT sceneLoaded(); diff --git a/src/DefaultHorizontalNodeGeometry.cpp b/src/DefaultHorizontalNodeGeometry.cpp index a30121d2..58679511 100644 --- a/src/DefaultHorizontalNodeGeometry.cpp +++ b/src/DefaultHorizontalNodeGeometry.cpp @@ -1,4 +1,5 @@ #include "DefaultHorizontalNodeGeometry.hpp" + #include "AbstractGraphModel.hpp" #include "NodeData.hpp" diff --git a/src/DefaultNodePainter.cpp b/src/DefaultNodePainter.cpp index d313d3a8..37c05964 100644 --- a/src/DefaultNodePainter.cpp +++ b/src/DefaultNodePainter.cpp @@ -55,43 +55,47 @@ void DefaultNodePainter::drawNodeRect(QPainter *painter, NodeGraphicsObject &ngo NodeStyle nodeStyle(json.object()); QVariant var = model.nodeData(nodeId, NodeRole::ValidationState); + bool invalid = false; QColor color = ngo.isSelected() ? nodeStyle.SelectedBoundaryColor : nodeStyle.NormalBoundaryColor; - auto validationState = NodeValidationState::State::Valid; if (var.canConvert()) { auto state = var.value(); - validationState = state._state; - switch (validationState) { - case NodeValidationState::State::Error: + switch (state._state) { + case NodeValidationState::State::Error: { + invalid = true; color = nodeStyle.ErrorColor; - break; - case NodeValidationState::State::Warning: + } break; + case NodeValidationState::State::Warning: { + invalid = true; color = nodeStyle.WarningColor; break; default: break; } + } } - float penWidth = ngo.nodeState().hovered() ? nodeStyle.HoveredPenWidth : nodeStyle.PenWidth; - if (validationState != NodeValidationState::State::Valid) { - float factor = (validationState == NodeValidationState::State::Error) ? 3.0f : 2.0f; - penWidth *= factor; + if (ngo.nodeState().hovered()) { + QPen p(color, nodeStyle.HoveredPenWidth); + painter->setPen(p); + } else { + QPen p(color, nodeStyle.PenWidth); + painter->setPen(p); } - QPen p(color, penWidth); - painter->setPen(p); - - QLinearGradient gradient(QPointF(0.0, 0.0), QPointF(2.0, size.height())); - gradient.setColorAt(0.0, nodeStyle.GradientColor0); - gradient.setColorAt(0.10, nodeStyle.GradientColor1); - gradient.setColorAt(0.90, nodeStyle.GradientColor2); - gradient.setColorAt(1.0, nodeStyle.GradientColor3); - - painter->setBrush(gradient); + if (invalid) { + painter->setBrush(color); + } else { + QLinearGradient gradient(QPointF(0.0, 0.0), QPointF(2.0, size.height())); + gradient.setColorAt(0.0, nodeStyle.GradientColor0); + gradient.setColorAt(0.10, nodeStyle.GradientColor1); + gradient.setColorAt(0.90, nodeStyle.GradientColor2); + gradient.setColorAt(1.0, nodeStyle.GradientColor3); + painter->setBrush(gradient); + } QRectF boundary(0, 0, size.width(), size.height()); double const radius = 3.0; @@ -354,25 +358,16 @@ void DefaultNodePainter::drawValidationIcon(QPainter *painter, NodeGraphicsObjec QColor color = (state._state == NodeValidationState::State::Error) ? nodeStyle.ErrorColor : nodeStyle.WarningColor; - QPointF center(size.width(), 0.0); - center += QPointF(iconSize.width() / 2.0, -iconSize.height() / 2.0); - - painter->save(); - - // Draw a colored circle behind the icon to highlight validation issues - painter->setPen(Qt::NoPen); - painter->setBrush(color); - painter->drawEllipse(center, iconSize.width() / 2.0 + 2.0, iconSize.height() / 2.0 + 2.0); - QPainter imgPainter(&pixmap); imgPainter.setCompositionMode(QPainter::CompositionMode_SourceIn); - imgPainter.fillRect(pixmap.rect(), nodeStyle.FontColor); + imgPainter.fillRect(pixmap.rect(), color); imgPainter.end(); + QPointF center(size.width(), 0.0); + center += QPointF(iconSize.width() / 2.0, -iconSize.height() / 2.0); + painter->drawPixmap(center.toPoint() - QPoint(iconSize.width() / 2, iconSize.height() / 2), pixmap); - - painter->restore(); } } // namespace QtNodes diff --git a/src/GraphicsView.cpp b/src/GraphicsView.cpp index 47081c92..1516aba4 100644 --- a/src/GraphicsView.cpp +++ b/src/GraphicsView.cpp @@ -3,6 +3,8 @@ #include "BasicGraphicsScene.hpp" #include "ConnectionGraphicsObject.hpp" #include "DataFlowGraphModel.hpp" +#include "Definitions.hpp" +#include "GroupGraphicsObject.hpp" #include "NodeGraphicsObject.hpp" #include "StyleCollection.hpp" #include "UndoCommands.hpp" @@ -32,6 +34,7 @@ GraphicsView::GraphicsView(QWidget *parent) : QGraphicsView(parent) , _clearSelectionAction(Q_NULLPTR) , _deleteSelectionAction(Q_NULLPTR) + , _cutSelectionAction(Q_NULLPTR) , _duplicateSelectionAction(Q_NULLPTR) , _copySelectionAction(Q_NULLPTR) , _pasteAction(Q_NULLPTR) @@ -118,6 +121,21 @@ void GraphicsView::setScene(BasicGraphicsScene *scene) addAction(_deleteSelectionAction); } + { + delete _cutSelectionAction; + _cutSelectionAction = new QAction(QStringLiteral("Cut Selection"), this); + _cutSelectionAction->setShortcutContext(Qt::ShortcutContext::WidgetShortcut); + _cutSelectionAction->setShortcut(QKeySequence(QKeySequence::Cut)); + _cutSelectionAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_X)); + _cutSelectionAction->setAutoRepeat(false); + connect(_cutSelectionAction, &QAction::triggered, [this] { + onCopySelectedObjects(); + onDeleteSelectedObjects(); + }); + + addAction(_cutSelectionAction); + } + { delete _duplicateSelectionAction; _duplicateSelectionAction = new QAction(QStringLiteral("Duplicate Selection"), this); @@ -183,21 +201,38 @@ void GraphicsView::centerScene() void GraphicsView::contextMenuEvent(QContextMenuEvent *event) { - if (itemAt(event->pos())) { - QGraphicsView::contextMenuEvent(event); - return; - } + QGraphicsView::contextMenuEvent(event); + QMenu *menu = nullptr; + const QPointF scenePos = mapToScene(event->pos()); - if (!nodeScene()) - return; + auto clickedItems = items(event->pos()); - auto const scenePos = mapToScene(event->pos()); + for (QGraphicsItem *item : clickedItems) { + if (auto *nodeItem = qgraphicsitem_cast(item)) { + Q_UNUSED(nodeItem); + menu = nodeScene()->createStdMenu(scenePos); + break; + } - QMenu *menu = nodeScene()->createSceneMenu(scenePos); + if (auto *groupItem = qgraphicsitem_cast(item)) { + menu = nodeScene()->createGroupMenu(scenePos, groupItem); + break; + } + } + + if (!menu) { + if (!clickedItems.empty()) { + menu = nodeScene()->createStdMenu(scenePos); + } else { + menu = nodeScene()->createSceneMenu(scenePos); + } + } if (menu) { menu->exec(event->globalPos()); } + + return; } void GraphicsView::wheelEvent(QWheelEvent *event) diff --git a/src/GroupGraphicsObject.cpp b/src/GroupGraphicsObject.cpp new file mode 100644 index 00000000..f9c995fb --- /dev/null +++ b/src/GroupGraphicsObject.cpp @@ -0,0 +1,222 @@ +#include "GroupGraphicsObject.hpp" +#include "BasicGraphicsScene.hpp" +#include "NodeConnectionInteraction.hpp" +#include "NodeGraphicsObject.hpp" +#include +#include +#include +#include + +using QtNodes::BasicGraphicsScene; +using QtNodes::ConnectionId; +using QtNodes::DataFlowGraphModel; +using QtNodes::GroupGraphicsObject; +using QtNodes::NodeConnectionInteraction; +using QtNodes::NodeGraphicsObject; +using QtNodes::NodeGroup; + +IconGraphicsItem::IconGraphicsItem(QGraphicsItem *parent) + : QGraphicsPixmapItem(parent) +{} + +IconGraphicsItem::IconGraphicsItem(const QPixmap &pixmap, QGraphicsItem *parent) + : QGraphicsPixmapItem(pixmap, parent) +{ + _scaleFactor = _iconSize / pixmap.size().width(); + setScale(_scaleFactor); +} + +double IconGraphicsItem::scaleFactor() const +{ + return _scaleFactor; +} + +GroupGraphicsObject::GroupGraphicsObject(BasicGraphicsScene &scene, NodeGroup &nodeGroup) + : _scene(scene) + , _group(nodeGroup) + , _possibleChild(nullptr) + , _locked(false) +{ + setRect(0, 0, _defaultWidth, _defaultHeight); + + _lockedGraphicsItem = new IconGraphicsItem(_lockedIcon, this); + _unlockedGraphicsItem = new IconGraphicsItem(_unlockedIcon, this); + + _scene.addItem(this); + + setFlag(QGraphicsItem::ItemIsMovable, true); + setFlag(QGraphicsItem::ItemIsFocusable, true); + setFlag(QGraphicsItem::ItemIsSelectable, true); + setFlag(QGraphicsItem::ItemDoesntPropagateOpacityToChildren, true); + + _currentFillColor = kUnlockedFillColor; + _currentBorderColor = kUnselectedBorderColor; + + _borderPen = QPen(_currentBorderColor, 1.0, Qt::PenStyle::DashLine); + + setZValue(-_groupAreaZValue); + + setAcceptHoverEvents(true); +} + +GroupGraphicsObject::~GroupGraphicsObject() +{ + _scene.removeItem(this); +} + +NodeGroup &GroupGraphicsObject::group() +{ + return _group; +} + +NodeGroup const &GroupGraphicsObject::group() const +{ + return _group; +} + +QRectF GroupGraphicsObject::boundingRect() const +{ + QRectF ret{}; + for (auto &node : _group.childNodes()) { + ret |= node->mapRectToScene(node->boundingRect()); + } + if (_possibleChild) { + ret |= _possibleChild->mapRectToScene(_possibleChild->boundingRect()); + } + return mapRectFromScene(ret.marginsAdded(_margins)); +} + +void GroupGraphicsObject::setFillColor(const QColor &color) +{ + _currentFillColor = color; + update(); +} + +void GroupGraphicsObject::setBorderColor(const QColor &color) +{ + _currentBorderColor = color; + _borderPen.setColor(_currentBorderColor); +} + +void GroupGraphicsObject::moveConnections() +{ + for (auto &node : group().childNodes()) { + node->moveConnections(); + } +} + +void GroupGraphicsObject::moveNodes(const QPointF &offset) +{ + for (auto &node : group().childNodes()) { + auto newPosition = QPointF(node->x() + offset.x(), node->y() + offset.y()); + node->setPos(newPosition); + node->update(); + } +} + +void GroupGraphicsObject::lock(bool locked) +{ + for (auto &node : _group.childNodes()) { + node->lock(locked); + } + _lockedGraphicsItem->setVisible(locked); + _unlockedGraphicsItem->setVisible(!locked); + setFillColor(locked ? kLockedFillColor : kUnlockedFillColor); + _locked = locked; + setZValue(locked ? _groupAreaZValue : -_groupAreaZValue); +} + +bool GroupGraphicsObject::locked() const +{ + return _locked; +} + +void GroupGraphicsObject::positionLockedIcon() +{ + _lockedGraphicsItem->setPos( + boundingRect().topRight() + + QPointF(-(_roundedBorderRadius + IconGraphicsItem::iconSize()), _roundedBorderRadius)); + _unlockedGraphicsItem->setPos( + boundingRect().topRight() + + QPointF(-(_roundedBorderRadius + IconGraphicsItem::iconSize()), _roundedBorderRadius)); +} + +void GroupGraphicsObject::setHovered(bool hovered) +{ + hovered ? setFillColor(locked() ? kLockedHoverColor : kUnlockedHoverColor) + : setFillColor(locked() ? kLockedFillColor : kUnlockedFillColor); + + for (auto &node : _group.childNodes()) { + auto ngo = node->nodeScene()->nodeGraphicsObject(node->nodeId()); + ngo->nodeState().setHovered(hovered); + node->update(); + } + update(); +} + +void GroupGraphicsObject::setPossibleChild(QtNodes::NodeGraphicsObject *possibleChild) +{ + _possibleChild = possibleChild; +} + +void GroupGraphicsObject::unsetPossibleChild() +{ + _possibleChild = nullptr; +} + +std::vector> GroupGraphicsObject::connections() const +{ + return _scene.connectionsWithinGroup(group().id()); +} + +void GroupGraphicsObject::setPosition(const QPointF &position) +{ + QPointF diffPos = position - scenePos(); + moveNodes(diffPos); + moveConnections(); +} + +void GroupGraphicsObject::hoverEnterEvent(QGraphicsSceneHoverEvent *event) +{ + Q_UNUSED(event); + setHovered(true); +} + +void GroupGraphicsObject::hoverLeaveEvent(QGraphicsSceneHoverEvent *event) +{ + Q_UNUSED(event); + setHovered(false); +} + +void GroupGraphicsObject::mouseMoveEvent(QGraphicsSceneMouseEvent *event) +{ + QGraphicsItem::mouseMoveEvent(event); + if (event->lastPos() != event->pos()) { + auto diff = event->pos() - event->lastPos(); + moveNodes(diff); + moveConnections(); + } +} + +void GroupGraphicsObject::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) +{ + QGraphicsItem::mouseDoubleClickEvent(event); + lock(!locked()); +} + +void GroupGraphicsObject::paint(QPainter *painter, + const QStyleOptionGraphicsItem *option, + QWidget *widget) +{ + Q_UNUSED(widget); + prepareGeometryChange(); + setRect(boundingRect()); + positionLockedIcon(); + painter->setClipRect(option->exposedRect); + painter->setBrush(_currentFillColor); + + setBorderColor(isSelected() ? kSelectedBorderColor : kUnselectedBorderColor); + painter->setPen(_borderPen); + + painter->drawRoundedRect(rect(), _roundedBorderRadius, _roundedBorderRadius); +} diff --git a/src/NodeGraphicsObject.cpp b/src/NodeGraphicsObject.cpp index d7b1bbe4..61babbb4 100644 --- a/src/NodeGraphicsObject.cpp +++ b/src/NodeGraphicsObject.cpp @@ -1,5 +1,4 @@ #include "NodeGraphicsObject.hpp" - #include "AbstractGraphModel.hpp" #include "AbstractNodeGeometry.hpp" #include "AbstractNodePainter.hpp" @@ -8,8 +7,10 @@ #include "ConnectionIdUtils.hpp" #include "NodeConnectionInteraction.hpp" #include "NodeDelegateModel.hpp" +#include "NodeGroup.hpp" #include "StyleCollection.hpp" #include "UndoCommands.hpp" +#include #include #include @@ -22,6 +23,10 @@ NodeGraphicsObject::NodeGraphicsObject(BasicGraphicsScene &scene, NodeId nodeId) : _nodeId(nodeId) , _graphModel(scene.graphModel()) , _nodeState(*this) + , _locked(false) + , _draggingIntoGroup(false) + , _possibleGroup(nullptr) + , _originalGroupSize() , _proxyWidget(nullptr) { scene.addItem(this); @@ -64,8 +69,6 @@ NodeGraphicsObject::NodeGraphicsObject(BasicGraphicsScene &scene, NodeId nodeId) if (_nodeId == nodeId) setLockedState(); }); - - QVariant var = _graphModel.nodeData(_nodeId, NodeRole::ProcessingStatus); } AbstractGraphModel &NodeGraphicsObject::graphModel() const @@ -141,6 +144,11 @@ void NodeGraphicsObject::setGeometryChanged() prepareGeometryChange(); } +void NodeGraphicsObject::setNodeGroup(std::shared_ptr group) +{ + _nodeGroup = group; +} + void NodeGraphicsObject::moveConnections() const { auto const &connected = _graphModel.allConnectionIds(_nodeId); @@ -188,7 +196,8 @@ QVariant NodeGraphicsObject::itemChange(GraphicsItemChange change, const QVarian void NodeGraphicsObject::mousePressEvent(QGraphicsSceneMouseEvent *event) { - if (graphModel().nodeFlags(_nodeId) & NodeFlag::Locked) { + if (_locked) { + nodeScene()->clearSelection(); return; } @@ -292,11 +301,50 @@ void NodeGraphicsObject::mouseMoveEvent(QGraphicsSceneMouseEvent *event) event->accept(); } } else { - auto diff = event->pos() - event->lastPos(); - - nodeScene()->undoStack().push(new MoveNodeCommand(nodeScene(), diff)); - - event->accept(); + QGraphicsObject::mouseMoveEvent(event); + + if (event->lastPos() != event->pos()) { + auto diff = event->pos() - event->lastPos(); + if (nodeScene()->groupingEnabled()) { + if (auto nodeGroup = _nodeGroup.lock(); nodeGroup) { + nodeGroup->groupGraphicsObject().moveConnections(); + if (nodeGroup->groupGraphicsObject().locked()) { + nodeGroup->groupGraphicsObject().moveNodes(diff); + } + } else { + moveConnections(); + // if it intersects with a group, expand group + QList overlapItems = collidingItems(); + for (auto &item : overlapItems) { + auto ggo = qgraphicsitem_cast(item); + if (ggo != nullptr) { + if (!ggo->locked()) { + if (!_draggingIntoGroup) { + _draggingIntoGroup = true; + _possibleGroup = ggo; + _originalGroupSize = _possibleGroup->mapRectToScene(ggo->rect()); + _possibleGroup->setPossibleChild(this); + break; + } else { + if (ggo == _possibleGroup) { + if (!boundingRect().intersects( + mapRectFromScene(_originalGroupSize))) { + _draggingIntoGroup = false; + _originalGroupSize = QRectF(); + _possibleGroup->unsetPossibleChild(); + _possibleGroup = nullptr; + } + } + } + } + } + } + } + } else { + moveConnections(); + } + } + event->ignore(); } QRectF r = nodeScene()->sceneRect(); @@ -315,6 +363,15 @@ void NodeGraphicsObject::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) // position connections precisely after fast node move moveConnections(); + if (nodeScene()->groupingEnabled() && _draggingIntoGroup && _possibleGroup + && _nodeGroup.expired()) { + nodeScene()->addNodeToGroup(_nodeId, _possibleGroup->group().id()); + _possibleGroup->unsetPossibleChild(); + _draggingIntoGroup = false; + _originalGroupSize = QRectF(); + _possibleGroup = nullptr; + } + nodeScene()->nodeClicked(_nodeId); } @@ -324,6 +381,11 @@ void NodeGraphicsObject::hoverEnterEvent(QGraphicsSceneHoverEvent *event) QList overlapItems = collidingItems(); for (QGraphicsItem *item : overlapItems) { + if (auto group = qgraphicsitem_cast(item)) { + Q_UNUSED(group); + continue; + } + if (item->zValue() > 0.0) { item->setZValue(0.0); } @@ -383,4 +445,26 @@ void NodeGraphicsObject::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) Q_EMIT nodeScene()->nodeContextMenu(_nodeId, mapToScene(event->pos())); } +void NodeGraphicsObject::lock(bool locked) +{ + _locked = locked; + + setFlag(QGraphicsItem::ItemIsFocusable, !locked); + setFlag(QGraphicsItem::ItemIsSelectable, !locked); +} + +QJsonObject NodeGraphicsObject::save() const +{ + QJsonObject nodeJson = _graphModel.saveNode(_nodeId); + if (nodeJson.isEmpty()) { + nodeJson["id"] = QString::number(_nodeId); + QJsonObject obj; + obj["x"] = pos().x(); + obj["y"] = pos().y(); + nodeJson["position"] = obj; + } + + return nodeJson; +} + } // namespace QtNodes diff --git a/src/NodeGroup.cpp b/src/NodeGroup.cpp new file mode 100644 index 00000000..901fb5e2 --- /dev/null +++ b/src/NodeGroup.cpp @@ -0,0 +1,126 @@ +#include "NodeGroup.hpp" +#include "ConnectionIdUtils.hpp" +#include "NodeConnectionInteraction.hpp" +#include +#include + +#include + +using QtNodes::DataFlowGraphModel; +using QtNodes::GroupGraphicsObject; +using QtNodes::NodeConnectionInteraction; +using QtNodes::NodeGraphicsObject; +using QtNodes::NodeGroup; +using QtNodes::NodeId; + +int NodeGroup::_groupCount = 0; + +NodeGroup::NodeGroup(std::vector nodes, + GroupId groupId, + QString name, + QObject *parent) + : QObject(parent) + , _name(std::move(name)) + , _id(groupId) + , _childNodes(std::move(nodes)) + , _groupGraphicsObject(nullptr) +{ + _groupCount++; +} + +QByteArray NodeGroup::saveToFile() const +{ + QJsonObject groupJson; + + groupJson["name"] = _name; + groupJson["id"] = static_cast(_id); + + QJsonArray nodesJson; + for (auto const &node : _childNodes) { + nodesJson.append(node->save()); + } + groupJson["nodes"] = nodesJson; + + QJsonArray connectionsJson; + auto groupConnections = _groupGraphicsObject->connections(); + for (auto const &connection : groupConnections) { + connectionsJson.append(toJson(*connection)); + } + groupJson["connections"] = connectionsJson; + + QJsonDocument groupDocument(groupJson); + + return groupDocument.toJson(); +} + +QtNodes::GroupId NodeGroup::id() const +{ + return _id; +} + +GroupGraphicsObject &NodeGroup::groupGraphicsObject() +{ + return *_groupGraphicsObject; +} + +GroupGraphicsObject const &NodeGroup::groupGraphicsObject() const +{ + return *_groupGraphicsObject; +} + +std::vector &NodeGroup::childNodes() +{ + return _childNodes; +} + +std::vector NodeGroup::nodeIDs() const +{ + std::vector ret{}; + ret.reserve(_childNodes.size()); + + for (auto const &node : _childNodes) { + ret.push_back(node->nodeId()); + } + + return ret; +} + +QString const &NodeGroup::name() const +{ + return _name; +} + +void NodeGroup::setGraphicsObject(std::unique_ptr &&graphics_object) +{ + _groupGraphicsObject = std::move(graphics_object); + _groupGraphicsObject->lock(true); +} + +bool NodeGroup::empty() const +{ + return _childNodes.empty(); +} + +int NodeGroup::groupCount() +{ + return _groupCount; +} + +void NodeGroup::addNode(NodeGraphicsObject *node) +{ + _childNodes.push_back(node); + if (_groupGraphicsObject && _groupGraphicsObject->locked()) { + node->lock(true); + } +} + +void NodeGroup::removeNode(NodeGraphicsObject *node) +{ + auto nodeIt = std::find(_childNodes.begin(), _childNodes.end(), node); + + if (nodeIt != _childNodes.end()) { + (*nodeIt)->unsetNodeGroup(); + _childNodes.erase(nodeIt); + groupGraphicsObject().positionLockedIcon(); + } +} diff --git a/src/UndoCommands.cpp b/src/UndoCommands.cpp index c98289cf..265d7e17 100644 --- a/src/UndoCommands.cpp +++ b/src/UndoCommands.cpp @@ -4,8 +4,10 @@ #include "ConnectionGraphicsObject.hpp" #include "ConnectionIdUtils.hpp" #include "Definitions.hpp" +#include "GroupGraphicsObject.hpp" #include "NodeGraphicsObject.hpp" +#include #include #include #include @@ -13,7 +15,6 @@ #include #include - namespace QtNodes { static QJsonObject serializeSelectedItems(BasicGraphicsScene *scene) @@ -25,16 +26,58 @@ static QJsonObject serializeSelectedItems(BasicGraphicsScene *scene) std::unordered_set selectedNodes; QJsonArray nodesJsonArray; + QJsonArray groupsJsonArray; + QJsonArray connJsonArray; + + auto appendNode = [&](NodeGraphicsObject *node) { + if (!node) + return; + + auto const inserted = selectedNodes.insert(node->nodeId()); + if (inserted.second) { + nodesJsonArray.append(graphModel.saveNode(node->nodeId())); + } + }; for (QGraphicsItem *item : scene->selectedItems()) { - if (auto n = qgraphicsitem_cast(item)) { - nodesJsonArray.append(graphModel.saveNode(n->nodeId())); + if (auto group = qgraphicsitem_cast(item)) { + for (auto *node : group->group().childNodes()) { + appendNode(node); - selectedNodes.insert(n->nodeId()); + for (auto const &connectionId : graphModel.allConnectionIds(node->nodeId())) { + connJsonArray.append(toJson(connectionId)); + } + } } } - QJsonArray connJsonArray; + for (QGraphicsItem *item : scene->selectedItems()) { + if (auto ngo = qgraphicsitem_cast(item)) { + appendNode(ngo); + + for (auto const &connectionId : graphModel.allConnectionIds(ngo->nodeId())) { + connJsonArray.append(toJson(connectionId)); + } + } + } + + for (QGraphicsItem *item : scene->selectedItems()) { + if (auto groupGo = qgraphicsitem_cast(item)) { + auto &group = groupGo->group(); + + QJsonObject groupJson; + groupJson["id"] = static_cast(group.id()); + groupJson["name"] = group.name(); + + QJsonArray nodeIdsJson; + for (NodeGraphicsObject *node : group.childNodes()) { + nodeIdsJson.append(static_cast(node->nodeId())); + } + + groupJson["nodes"] = nodeIdsJson; + groupsJsonArray.append(groupJson); + } + } for (QGraphicsItem *item : scene->selectedItems()) { if (auto c = qgraphicsitem_cast(item)) { @@ -46,6 +89,7 @@ static QJsonObject serializeSelectedItems(BasicGraphicsScene *scene) } } + serializedScene["groups"] = groupsJsonArray; serializedScene["nodes"] = nodesJsonArray; serializedScene["connections"] = connJsonArray; @@ -80,6 +124,28 @@ static void insertSerializedItems(QJsonObject const &json, BasicGraphicsScene *s scene->connectionGraphicsObject(connId)->setSelected(true); } + + if (json.contains("groups")) { + QJsonArray groupsJsonArray = json["groups"].toArray(); + + for (const QJsonValue &groupValue : groupsJsonArray) { + QJsonObject groupJson = groupValue.toObject(); + + QString name = QString("Group %1").arg(NodeGroup::groupCount()); + QJsonArray nodeIdsJson = groupJson["nodes"].toArray(); + + std::vector groupNodes; + + for (const QJsonValue &idVal : nodeIdsJson) { + NodeId nodeId = static_cast(idVal.toInt()); + if (auto *ngo = scene->nodeGraphicsObject(nodeId)) { + groupNodes.push_back(ngo); + } + } + + scene->createGroup(groupNodes, name); + } + } } static void deleteSerializedItems(QJsonObject &sceneJson, AbstractGraphModel &graphModel) @@ -175,23 +241,65 @@ DeleteCommand::DeleteCommand(BasicGraphicsScene *scene) QJsonArray nodesJsonArray; // Delete the nodes; this will delete many of the connections. // Selected connections were already deleted prior to this loop, + + std::unordered_set processedNodes; + + auto appendNode = [&](NodeGraphicsObject *node) { + if (!node) + return; + + auto const inserted = processedNodes.insert(node->nodeId()); + if (!inserted.second) + return; + + for (auto const &cid : graphModel.allConnectionIds(node->nodeId())) { + connJsonArray.append(toJson(cid)); + } + + nodesJsonArray.append(graphModel.saveNode(node->nodeId())); + }; + + QJsonArray groupsJsonArray; + for (QGraphicsItem *item : _scene->selectedItems()) { - if (auto n = qgraphicsitem_cast(item)) { - // saving connections attached to the selected nodes - for (auto const &cid : graphModel.allConnectionIds(n->nodeId())) { - connJsonArray.append(toJson(cid)); + if (auto groupGo = qgraphicsitem_cast(item)) { + auto &groupData = groupGo->group(); + + QJsonArray groupNodeIdsJsonArray; + for (NodeGraphicsObject *node : groupData.childNodes()) { + appendNode(node); + groupNodeIdsJsonArray.append(static_cast(node->nodeId())); + } + + QJsonObject groupJson; + groupJson["id"] = static_cast(groupData.id()); + groupJson["name"] = groupData.name(); + groupJson["nodes"] = groupNodeIdsJsonArray; + groupsJsonArray.append(groupJson); + } + } + + for (QGraphicsItem *item : _scene->selectedItems()) { + if (auto group = qgraphicsitem_cast(item)) { + for (auto *node : group->group().childNodes()) { + appendNode(node); } + } + } - nodesJsonArray.append(graphModel.saveNode(n->nodeId())); + for (QGraphicsItem *item : _scene->selectedItems()) { + if (auto n = qgraphicsitem_cast(item)) { + appendNode(n); } } // If nothing is deleted, cancel this operation - if (connJsonArray.isEmpty() && nodesJsonArray.isEmpty()) + if (connJsonArray.isEmpty() && nodesJsonArray.isEmpty() && groupsJsonArray.isEmpty()) setObsolete(true); _sceneJson["nodes"] = nodesJsonArray; _sceneJson["connections"] = connJsonArray; + _sceneJson["groups"] = groupsJsonArray; } void DeleteCommand::undo() @@ -365,6 +473,28 @@ QJsonObject PasteCommand::makeNewNodeIdsInScene(QJsonObject const &sceneJson) newSceneJson["nodes"] = newNodesJsonArray; newSceneJson["connections"] = newConnJsonArray; + if (sceneJson.contains("groups")) { + QJsonArray groupsJsonArray = sceneJson["groups"].toArray(); + QJsonArray newGroupsJsonArray; + + for (const QJsonValue &groupVal : groupsJsonArray) { + QJsonObject groupJson = groupVal.toObject(); + QJsonArray nodeIdsJson = groupJson["nodes"].toArray(); + + QJsonArray newNodeIdsJson; + for (const QJsonValue &idVal : nodeIdsJson) { + NodeId oldId = static_cast(idVal.toInt()); + NodeId newId = mapNodeIds[oldId]; + newNodeIdsJson.append(static_cast(newId)); + } + + groupJson["nodes"] = newNodeIdsJson; + newGroupsJsonArray.append(groupJson); + } + + newSceneJson["groups"] = newGroupsJsonArray; + } + return newSceneJson; } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 68125133..8ff7af55 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -14,6 +14,7 @@ add_executable(test_nodes src/TestSerialization.cpp src/TestUndoCommands.cpp src/TestBasicGraphicsScene.cpp + src/TestNodeGroup.cpp src/TestUIInteraction.cpp src/TestDataFlow.cpp include/ApplicationSetup.hpp diff --git a/test/src/TestNodeGroup.cpp b/test/src/TestNodeGroup.cpp new file mode 100644 index 00000000..fa0f818c --- /dev/null +++ b/test/src/TestNodeGroup.cpp @@ -0,0 +1,372 @@ +#include "ApplicationSetup.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +using QtNodes::BasicGraphicsScene; +using QtNodes::ConnectionId; +using QtNodes::DataFlowGraphModel; +using QtNodes::NodeDelegateModel; +using QtNodes::NodeDelegateModelRegistry; +using QtNodes::GroupId; +using QtNodes::NodeGraphicsObject; +using QtNodes::NodeGroup; +using QtNodes::NodeId; +using QtNodes::NodeRole; +using QtNodes::PortIndex; +using QtNodes::PortType; + +namespace { +class DummyNodeModel : public NodeDelegateModel +{ +public: + QString caption() const override { return QStringLiteral("Dummy Node"); } + QString name() const override { return QStringLiteral("DummyNode"); } + + unsigned int nPorts(PortType portType) const override + { + Q_UNUSED(portType); + return 1U; + } + + QtNodes::NodeDataType dataType(PortType, PortIndex) const override { return {}; } + + std::shared_ptr outData(PortIndex const) override { return nullptr; } + + void setInData(std::shared_ptr, PortIndex const) override {} + + QWidget *embeddedWidget() override { return nullptr; } +}; + +std::shared_ptr createDummyRegistry() +{ + auto registry = std::make_shared(); + registry->registerModel(QStringLiteral("Test")); + return registry; +} + +NodeId createNode(DataFlowGraphModel &model, BasicGraphicsScene &scene) +{ + NodeId nodeId = model.addNode(QStringLiteral("DummyNode")); + REQUIRE(nodeId != QtNodes::InvalidNodeId); + + QCoreApplication::processEvents(); + + auto *nodeObject = scene.nodeGraphicsObject(nodeId); + REQUIRE(nodeObject != nullptr); + + return nodeId; +} + +std::set toNodeIdSet(std::vector const &ids) +{ + return {ids.begin(), ids.end()}; +} + +} // namespace + +TEST_CASE("Node group creation", "[node-group]") +{ + auto app = applicationSetup(); + + auto registry = createDummyRegistry(); + DataFlowGraphModel model(registry); + BasicGraphicsScene scene(model); + scene.setGroupingEnabled(true); + + SECTION("Creating a group from a single node") + { + NodeId nodeId = createNode(model, scene); + auto *nodeObject = scene.nodeGraphicsObject(nodeId); + REQUIRE(nodeObject != nullptr); + + std::vector nodes{nodeObject}; + auto groupWeak = scene.createGroup(nodes, QStringLiteral("SingleGroup")); + auto group = groupWeak.lock(); + + REQUIRE(group); + CHECK(scene.groups().size() == 1); + REQUIRE(group->childNodes().size() == 1); + CHECK(group->childNodes().front()->nodeId() == nodeId); + + auto groupIds = group->nodeIDs(); + REQUIRE(groupIds.size() == 1); + CHECK(groupIds.front() == nodeId); + + auto nodeGroup = nodeObject->nodeGroup().lock(); + REQUIRE(nodeGroup); + CHECK(nodeGroup->id() == group->id()); + } + + SECTION("Creating multiple groups and verifying membership") + { + constexpr std::size_t nodesPerGroup = 3; + constexpr std::size_t groupCount = 2; + + std::vector> expectedNodeIds(groupCount); + std::vector groupIds; + + for (std::size_t groupIndex = 0; groupIndex < groupCount; ++groupIndex) { + std::vector nodeObjects; + nodeObjects.reserve(nodesPerGroup); + expectedNodeIds[groupIndex].reserve(nodesPerGroup); + + for (std::size_t i = 0; i < nodesPerGroup; ++i) { + NodeId nodeId = createNode(model, scene); + expectedNodeIds[groupIndex].push_back(nodeId); + nodeObjects.push_back(scene.nodeGraphicsObject(nodeId)); + } + + auto groupWeak = scene.createGroup(nodeObjects, + QStringLiteral("Group%1").arg(groupIndex)); + auto group = groupWeak.lock(); + REQUIRE(group); + groupIds.push_back(group->id()); + } + + CHECK(scene.groups().size() == groupCount); + + for (std::size_t index = 0; index < groupIds.size(); ++index) { + auto const &groupId = groupIds[index]; + auto groupIt = scene.groups().find(groupId); + REQUIRE(groupIt != scene.groups().end()); + + auto const &group = groupIt->second; + REQUIRE(group); + + auto const nodeIds = group->nodeIDs(); + CHECK(toNodeIdSet(nodeIds) == toNodeIdSet(expectedNodeIds[index])); + + for (auto *node : group->childNodes()) { + REQUIRE(node); + auto nodeGroup = node->nodeGroup().lock(); + REQUIRE(nodeGroup); + CHECK(nodeGroup->id() == groupId); + } + } + } +} + +TEST_CASE("Adding and removing nodes from a group", "[node-group]") +{ + auto app = applicationSetup(); + + auto registry = createDummyRegistry(); + DataFlowGraphModel model(registry); + BasicGraphicsScene scene(model); + scene.setGroupingEnabled(true); + + SECTION("Adding a node to an existing group") + { + NodeId firstNodeId = createNode(model, scene); + auto *firstNode = scene.nodeGraphicsObject(firstNodeId); + REQUIRE(firstNode != nullptr); + + std::vector nodes{firstNode}; + auto group = scene.createGroup(nodes, QStringLiteral("ExtendableGroup")).lock(); + REQUIRE(group); + + NodeId extraNodeId = createNode(model, scene); + auto *extraNode = scene.nodeGraphicsObject(extraNodeId); + REQUIRE(extraNode != nullptr); + + scene.addNodeToGroup(extraNodeId, group->id()); + + auto const groupIds = group->nodeIDs(); + CHECK(groupIds.size() == 2); + CHECK(std::find(groupIds.begin(), groupIds.end(), extraNodeId) != groupIds.end()); + + auto nodeGroup = extraNode->nodeGroup().lock(); + REQUIRE(nodeGroup); + CHECK(nodeGroup->id() == group->id()); + } + + SECTION("Removing nodes from a group and clearing empty groups") + { + std::vector nodes; + std::vector nodeIds; + nodes.reserve(2); + nodeIds.reserve(2); + + for (int i = 0; i < 2; ++i) { + NodeId id = createNode(model, scene); + nodeIds.push_back(id); + nodes.push_back(scene.nodeGraphicsObject(id)); + } + + auto group = scene.createGroup(nodes, QStringLiteral("RemovableGroup")).lock(); + REQUIRE(group); + auto groupId = group->id(); + + scene.removeNodeFromGroup(nodeIds.front()); + auto const remainingIds = group->nodeIDs(); + CHECK(std::find(remainingIds.begin(), remainingIds.end(), nodeIds.front()) + == remainingIds.end()); + + auto *removedNode = scene.nodeGraphicsObject(nodeIds.front()); + REQUIRE(removedNode != nullptr); + CHECK(removedNode->nodeGroup().expired()); + + scene.removeNodeFromGroup(nodeIds.back()); + CHECK(scene.groups().find(groupId) == scene.groups().end()); + } + + SECTION("Deleting grouped nodes updates the scene") + { + std::vector nodes; + std::vector nodeIds; + nodes.reserve(2); + nodeIds.reserve(2); + + for (int i = 0; i < 2; ++i) { + NodeId id = createNode(model, scene); + nodeIds.push_back(id); + nodes.push_back(scene.nodeGraphicsObject(id)); + } + + auto group = scene.createGroup(nodes, QStringLiteral("DeletionGroup")).lock(); + REQUIRE(group); + auto groupId = group->id(); + + model.deleteNode(nodeIds.front()); + QCoreApplication::processEvents(); + + CHECK(scene.groups().find(groupId) != scene.groups().end()); + + model.deleteNode(nodeIds.back()); + QCoreApplication::processEvents(); + + CHECK(scene.groups().find(groupId) == scene.groups().end()); + CHECK(scene.nodeGraphicsObject(nodeIds.front()) == nullptr); + CHECK(scene.nodeGraphicsObject(nodeIds.back()) == nullptr); + } +} + +TEST_CASE("Saving and restoring node groups", "[node-group]") +{ + auto app = applicationSetup(); + + auto registry = createDummyRegistry(); + DataFlowGraphModel model(registry); + BasicGraphicsScene scene(model); + scene.setGroupingEnabled(true); + + SECTION("Saving a group serializes nodes and connections") + { + std::vector nodeObjects; + std::vector nodeIds; + nodeObjects.reserve(2); + nodeIds.reserve(2); + + for (int i = 0; i < 2; ++i) { + NodeId nodeId = createNode(model, scene); + nodeIds.push_back(nodeId); + nodeObjects.push_back(scene.nodeGraphicsObject(nodeId)); + model.setNodeData(nodeId, NodeRole::Position, QPointF(100.0 * i, 50.0 * i)); + } + + auto group = scene.createGroup(nodeObjects, QStringLiteral("SerializableGroup")).lock(); + REQUIRE(group); + + ConnectionId connection{nodeIds[0], 0, nodeIds[1], 0}; + model.addConnection(connection); + QCoreApplication::processEvents(); + + auto groupJson = QJsonDocument::fromJson(group->saveToFile()).object(); + CHECK(groupJson["name"].toString() == QStringLiteral("SerializableGroup")); + CHECK(static_cast(groupJson["id"].toInt()) == group->id()); + + auto nodesJson = groupJson["nodes"].toArray(); + CHECK(nodesJson.size() == 2); + + std::set serializedIds; + for (auto const &nodeValue : nodesJson) { + auto nodeObject = nodeValue.toObject(); + NodeId serializedId = static_cast(nodeObject["id"].toInt()); + serializedIds.insert(serializedId); + CHECK(nodeObject.contains("position")); + } + CHECK(serializedIds == toNodeIdSet(nodeIds)); + + auto connectionsJson = groupJson["connections"].toArray(); + CHECK(connectionsJson.size() == 1); + + auto connectionObject = connectionsJson.first().toObject(); + CHECK(static_cast(connectionObject["outNodeId"].toInt()) == nodeIds[0]); + CHECK(static_cast(connectionObject["intNodeId"].toInt()) == nodeIds[1]); + } + + SECTION("Restoring a group from serialized data") + { + std::vector nodeObjects; + std::vector nodeIds; + nodeObjects.reserve(2); + nodeIds.reserve(2); + + for (int i = 0; i < 2; ++i) { + NodeId nodeId = createNode(model, scene); + nodeIds.push_back(nodeId); + nodeObjects.push_back(scene.nodeGraphicsObject(nodeId)); + model.setNodeData(nodeId, NodeRole::Position, QPointF(150.0 * i, 60.0 * i)); + } + + auto group = scene.createGroup(nodeObjects, QStringLiteral("OriginalGroup")).lock(); + REQUIRE(group); + + ConnectionId connection{nodeIds[0], 0, nodeIds[1], 0}; + model.addConnection(connection); + QCoreApplication::processEvents(); + + auto groupJson = QJsonDocument::fromJson(group->saveToFile()).object(); + + auto newRegistry = createDummyRegistry(); + DataFlowGraphModel newModel(newRegistry); + BasicGraphicsScene newScene(newModel); + newScene.setGroupingEnabled(true); + + auto [restoredGroupWeak, idMapping] = newScene.restoreGroup(groupJson); + auto restoredGroup = restoredGroupWeak.lock(); + REQUIRE(restoredGroup); + + CHECK(newScene.groups().find(restoredGroup->id()) != newScene.groups().end()); + + auto restoredIds = restoredGroup->nodeIDs(); + CHECK(restoredIds.size() == nodeIds.size()); + + for (auto originalId : nodeIds) { + auto mappingIt = idMapping.find(static_cast(originalId)); + REQUIRE(mappingIt != idMapping.end()); + NodeId restoredId = static_cast(mappingIt->second); + CHECK(std::find(restoredIds.begin(), restoredIds.end(), restoredId) + != restoredIds.end()); + } + + REQUIRE_FALSE(restoredIds.empty()); + auto connections = newModel.allConnectionIds(restoredIds.front()); + REQUIRE_FALSE(connections.empty()); + + auto connectionIt = connections.begin(); + std::set restoredSet(restoredIds.begin(), restoredIds.end()); + CHECK(restoredSet.count(connectionIt->outNodeId) == 1); + CHECK(restoredSet.count(connectionIt->inNodeId) == 1); + } +}