From cb06e100fc3bee787abb5d601f4cc8f557ec227a Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Sun, 23 Nov 2025 21:04:23 -0300 Subject: [PATCH 01/33] start --- .gitignore | 2 +- CMakeLists.txt | 30 ++++ examples/CMakeLists.txt | 4 + examples/qml_calculator/AdditionModel.hpp | 57 +++++++ examples/qml_calculator/CMakeLists.txt | 19 +++ examples/qml_calculator/DecimalData.hpp | 27 ++++ .../qml_calculator/QmlCalculatorResources.qrc | 5 + .../QmlNumberDisplayDataModel.hpp | 54 +++++++ .../QmlNumberSourceDataModel.hpp | 74 +++++++++ examples/qml_calculator/main.cpp | 55 +++++++ examples/qml_calculator/main.qml | 68 ++++++++ include/QtNodes/qml/ConnectionsListModel.hpp | 38 +++++ include/QtNodes/qml/NodesListModel.hpp | 48 ++++++ include/QtNodes/qml/QuickGraphModel.hpp | 45 ++++++ resources/qml.qrc | 7 + resources/qml/Connection.qml | 47 ++++++ resources/qml/Node.qml | 139 +++++++++++++++++ resources/qml/NodeGraph.qml | 147 ++++++++++++++++++ src/qml/ConnectionsListModel.cpp | 98 ++++++++++++ src/qml/NodesListModel.cpp | 133 ++++++++++++++++ src/qml/QuickGraphModel.cpp | 91 +++++++++++ 21 files changed, 1187 insertions(+), 1 deletion(-) create mode 100644 examples/qml_calculator/AdditionModel.hpp create mode 100644 examples/qml_calculator/CMakeLists.txt create mode 100644 examples/qml_calculator/DecimalData.hpp create mode 100644 examples/qml_calculator/QmlCalculatorResources.qrc create mode 100644 examples/qml_calculator/QmlNumberDisplayDataModel.hpp create mode 100644 examples/qml_calculator/QmlNumberSourceDataModel.hpp create mode 100644 examples/qml_calculator/main.cpp create mode 100644 examples/qml_calculator/main.qml create mode 100644 include/QtNodes/qml/ConnectionsListModel.hpp create mode 100644 include/QtNodes/qml/NodesListModel.hpp create mode 100644 include/QtNodes/qml/QuickGraphModel.hpp create mode 100644 resources/qml.qrc create mode 100644 resources/qml/Connection.qml create mode 100644 resources/qml/Node.qml create mode 100644 resources/qml/NodeGraph.qml create mode 100644 src/qml/ConnectionsListModel.cpp create mode 100644 src/qml/NodesListModel.cpp create mode 100644 src/qml/QuickGraphModel.cpp diff --git a/.gitignore b/.gitignore index 552a9b2f0..9829c6242 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ *.pyc CMakeLists.txt.user - +.idea build*/ .vscode/ diff --git a/CMakeLists.txt b/CMakeLists.txt index cf036012f..f369c497d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,6 +33,7 @@ option(BUILD_SHARED_LIBS "Build as shared library" ON) option(BUILD_DEBUG_POSTFIX_D "Append d suffix to debug libraries" OFF) option(QT_NODES_FORCE_TEST_COLOR "Force colorized unit test output" OFF) option(USE_QT6 "Build with Qt6 (Enabled by default)" ON) +option(BUILD_QML "Build QML support" ON) if(QT_NODES_DEVELOPER_DEFAULTS) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}/bin") @@ -56,6 +57,14 @@ else() endif() find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets Gui OpenGL) +if(BUILD_QML) + find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Quick Qml) + if(NOT Qt${QT_VERSION_MAJOR}Quick_FOUND) + message(WARNING "Qt Quick not found, QML support disabled") + set(BUILD_QML OFF) + endif() +endif() + message(STATUS "QT_VERSION: ${QT_VERSION}, QT_DIR: ${QT_DIR}") if (${QT_VERSION} VERSION_LESS 5.11.0) @@ -90,6 +99,15 @@ set(CPP_SOURCE_FILES resources/resources.qrc ) +if(BUILD_QML) + list(APPEND CPP_SOURCE_FILES + src/qml/NodesListModel.cpp + src/qml/ConnectionsListModel.cpp + src/qml/QuickGraphModel.cpp + resources/qml.qrc + ) +endif() + set(HPP_HEADER_FILES include/QtNodes/internal/AbstractConnectionPainter.hpp include/QtNodes/internal/AbstractGraphModel.hpp @@ -129,6 +147,14 @@ set(HPP_HEADER_FILES include/QtNodes/internal/UndoCommands.hpp ) +if(BUILD_QML) + list(APPEND HPP_HEADER_FILES + include/QtNodes/qml/NodesListModel.hpp + include/QtNodes/qml/ConnectionsListModel.hpp + include/QtNodes/qml/QuickGraphModel.hpp + ) +endif() + # If we want to give the option to build a static library, # set BUILD_SHARED_LIBS option to OFF add_library(QtNodes @@ -156,6 +182,10 @@ target_link_libraries(QtNodes Qt${QT_VERSION_MAJOR}::OpenGL ) +if(BUILD_QML) + target_link_libraries(QtNodes PUBLIC Qt${QT_VERSION_MAJOR}::Quick Qt${QT_VERSION_MAJOR}::Qml) +endif() + target_compile_definitions(QtNodes PUBLIC $, NODE_EDITOR_SHARED, NODE_EDITOR_STATIC> diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 49494da2e..3816f01fd 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -16,3 +16,7 @@ add_subdirectory(dynamic_ports) add_subdirectory(lock_nodes_and_connections) +if(BUILD_QML) + add_subdirectory(qml_calculator) +endif() + diff --git a/examples/qml_calculator/AdditionModel.hpp b/examples/qml_calculator/AdditionModel.hpp new file mode 100644 index 000000000..ebe14b93e --- /dev/null +++ b/examples/qml_calculator/AdditionModel.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include +#include "DecimalData.hpp" + +using QtNodes::NodeData; +using QtNodes::NodeDataType; +using QtNodes::NodeDelegateModel; +using QtNodes::PortIndex; +using QtNodes::PortType; + +class AdditionModel : public NodeDelegateModel +{ + Q_OBJECT +public: + AdditionModel() {} + + QString caption() const override { return QStringLiteral("Addition"); } + QString name() const override { return QStringLiteral("Addition"); } + + unsigned int nPorts(PortType portType) const override { + if (portType == PortType::In) return 2; + else return 1; + } + + NodeDataType dataType(PortType, PortIndex) const override { + return DecimalData().type(); + } + + std::shared_ptr outData(PortIndex) override { + return _result; + } + + void setInData(std::shared_ptr data, PortIndex portIndex) override { + auto numberData = std::dynamic_pointer_cast(data); + if (portIndex == 0) _number1 = numberData; + else _number2 = numberData; + + compute(); + } + + QWidget *embeddedWidget() override { return nullptr; } + +private: + void compute() { + if (_number1 && _number2) { + _result = std::make_shared(_number1->number() + _number2->number()); + } else { + _result.reset(); + } + Q_EMIT dataUpdated(0); + } + + std::shared_ptr _number1; + std::shared_ptr _number2; + std::shared_ptr _result; +}; diff --git a/examples/qml_calculator/CMakeLists.txt b/examples/qml_calculator/CMakeLists.txt new file mode 100644 index 000000000..025f0d8f9 --- /dev/null +++ b/examples/qml_calculator/CMakeLists.txt @@ -0,0 +1,19 @@ +set(TARGET_NAME qml_calculator) + +add_executable(${TARGET_NAME} + main.cpp + QmlCalculatorResources.qrc + QmlNumberSourceDataModel.hpp + QmlNumberDisplayDataModel.hpp + AdditionModel.hpp + DecimalData.hpp +) + +target_link_libraries(${TARGET_NAME} + PRIVATE + QtNodes::QtNodes + Qt${QT_VERSION_MAJOR}::Quick + Qt${QT_VERSION_MAJOR}::Qml + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui +) diff --git a/examples/qml_calculator/DecimalData.hpp b/examples/qml_calculator/DecimalData.hpp new file mode 100644 index 000000000..caefe3161 --- /dev/null +++ b/examples/qml_calculator/DecimalData.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include + +using QtNodes::NodeData; +using QtNodes::NodeDataType; + +class DecimalData : public NodeData +{ +public: + DecimalData() + : _number(0.0) + {} + + DecimalData(double const number) + : _number(number) + {} + + NodeDataType type() const override { return NodeDataType{"decimal", "Decimal"}; } + + double number() const { return _number; } + + QString numberAsText() const { return QString::number(_number, 'f'); } + +private: + double _number; +}; diff --git a/examples/qml_calculator/QmlCalculatorResources.qrc b/examples/qml_calculator/QmlCalculatorResources.qrc new file mode 100644 index 000000000..5f6483ac3 --- /dev/null +++ b/examples/qml_calculator/QmlCalculatorResources.qrc @@ -0,0 +1,5 @@ + + + main.qml + + diff --git a/examples/qml_calculator/QmlNumberDisplayDataModel.hpp b/examples/qml_calculator/QmlNumberDisplayDataModel.hpp new file mode 100644 index 000000000..a0a946af2 --- /dev/null +++ b/examples/qml_calculator/QmlNumberDisplayDataModel.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include "DecimalData.hpp" + +using QtNodes::NodeData; +using QtNodes::NodeDataType; +using QtNodes::NodeDelegateModel; +using QtNodes::PortIndex; +using QtNodes::PortType; + +class QmlNumberDisplayDataModel : public NodeDelegateModel +{ + Q_OBJECT + Q_PROPERTY(QString displayedText READ displayedText NOTIFY displayedTextChanged) + +public: + QmlNumberDisplayDataModel() {} + + QString caption() const override { return QStringLiteral("Result"); } + QString name() const override { return QStringLiteral("NumberDisplay"); } + bool captionVisible() const override { return false; } + + unsigned int nPorts(PortType portType) const override { + return (portType == PortType::In) ? 1 : 0; + } + + NodeDataType dataType(PortType, PortIndex) const override { + return DecimalData().type(); + } + + std::shared_ptr outData(PortIndex) override { return nullptr; } + + void setInData(std::shared_ptr data, PortIndex) override { + auto numberData = std::dynamic_pointer_cast(data); + if (numberData) { + _displayedText = numberData->numberAsText(); + } else { + _displayedText = "---"; + } + Q_EMIT displayedTextChanged(); + } + + QWidget *embeddedWidget() override { return nullptr; } + + QString displayedText() const { return _displayedText; } + +Q_SIGNALS: + void displayedTextChanged(); + +private: + QString _displayedText = "---"; +}; diff --git a/examples/qml_calculator/QmlNumberSourceDataModel.hpp b/examples/qml_calculator/QmlNumberSourceDataModel.hpp new file mode 100644 index 000000000..39b02a54e --- /dev/null +++ b/examples/qml_calculator/QmlNumberSourceDataModel.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include "DecimalData.hpp" + +using QtNodes::NodeData; +using QtNodes::NodeDataType; +using QtNodes::NodeDelegateModel; +using QtNodes::PortIndex; +using QtNodes::PortType; + +class QmlNumberSourceDataModel : public NodeDelegateModel +{ + Q_OBJECT + Q_PROPERTY(double number READ number WRITE setNumber NOTIFY numberChanged) + +public: + QmlNumberSourceDataModel() : _number(std::make_shared(0.0)) {} + + QString caption() const override { return QStringLiteral("Number Source"); } + QString name() const override { return QStringLiteral("NumberSource"); } + bool captionVisible() const override { return false; } + + unsigned int nPorts(PortType portType) const override { + return (portType == PortType::Out) ? 1 : 0; + } + + NodeDataType dataType(PortType, PortIndex) const override { + return DecimalData().type(); + } + + std::shared_ptr outData(PortIndex) override { + return _number; + } + + void setInData(std::shared_ptr, PortIndex) override {} + + QWidget *embeddedWidget() override { return nullptr; } + + double number() const { return _number->number(); } + + void setNumber(double n) { + if (_number->number() != n) { + _number = std::make_shared(n); + Q_EMIT dataUpdated(0); + Q_EMIT numberChanged(); + } + } + + QJsonObject save() const override { + QJsonObject modelJson = NodeDelegateModel::save(); + modelJson["number"] = QString::number(_number->number()); + return modelJson; + } + + void load(QJsonObject const &p) override { + QJsonValue v = p["number"]; + if (!v.isUndefined()) { + QString strNum = v.toString(); + bool ok; + double d = strNum.toDouble(&ok); + if (ok) { + setNumber(d); + } + } + } + +Q_SIGNALS: + void numberChanged(); + +private: + std::shared_ptr _number; +}; diff --git a/examples/qml_calculator/main.cpp b/examples/qml_calculator/main.cpp new file mode 100644 index 000000000..ccfed2118 --- /dev/null +++ b/examples/qml_calculator/main.cpp @@ -0,0 +1,55 @@ +#include +#include +#include + +#include +#include +#include +#include + +#include "QmlNumberSourceDataModel.hpp" +#include "QmlNumberDisplayDataModel.hpp" +#include "AdditionModel.hpp" + +using QtNodes::NodeDelegateModelRegistry; +using QtNodes::QuickGraphModel; + +static std::shared_ptr registerDataModels() +{ + auto ret = std::make_shared(); + ret->registerModel("NumberSource"); + ret->registerModel("NumberDisplay"); + ret->registerModel("Addition"); + return ret; +} + +int main(int argc, char *argv[]) +{ + QGuiApplication app(argc, argv); + + qmlRegisterType("QtNodes", 1, 0, "QuickGraphModel"); + qmlRegisterType("QtNodes", 1, 0, "NodesListModel"); + qmlRegisterType("QtNodes", 1, 0, "ConnectionsListModel"); + + qmlRegisterType(QUrl("qrc:/QtNodes/QML/qml/NodeGraph.qml"), "QtNodes", 1, 0, "NodeGraph"); + qmlRegisterType(QUrl("qrc:/QtNodes/QML/qml/Node.qml"), "QtNodes", 1, 0, "Node"); + qmlRegisterType(QUrl("qrc:/QtNodes/QML/qml/Connection.qml"), "QtNodes", 1, 0, "Connection"); + + auto registry = registerDataModels(); + auto graphModel = new QuickGraphModel(); + graphModel->setRegistry(registry); + + QQmlApplicationEngine engine; + + engine.rootContext()->setContextProperty("_graphModel", graphModel); + + const QUrl url(QStringLiteral("qrc:/main.qml")); + QObject::connect(&engine, &QQmlApplicationEngine::objectCreated, + &app, [url](QObject *obj, const QUrl &objUrl) { + if (!obj && url == objUrl) + QCoreApplication::exit(-1); + }, Qt::QueuedConnection); + engine.load(url); + + return app.exec(); +} diff --git a/examples/qml_calculator/main.qml b/examples/qml_calculator/main.qml new file mode 100644 index 000000000..e44ebba53 --- /dev/null +++ b/examples/qml_calculator/main.qml @@ -0,0 +1,68 @@ +import QtQuick 2.15 +import QtQuick.Window 2.15 +import QtQuick.Controls 2.15 +import QtNodes 1.0 + +Window { + visible: true + width: 1024 + height: 768 + title: "QML Calculator" + + // Context property set from C++ + property QuickGraphModel model: _graphModel + + Column { + anchors.fill: parent + + Row { + height: 40 + spacing: 10 + padding: 5 + Button { text: "Add Source"; onClicked: model.addNode("NumberSource") } + Button { text: "Add Display"; onClicked: model.addNode("NumberDisplay") } + Button { text: "Add Addition"; onClicked: model.addNode("Addition") } + } + + NodeGraph { + width: parent.width + height: parent.height - 40 + graphModel: model + + nodeContentDelegate: Component { + Item { + // delegateModel and nodeType are provided by the Loader in Node.qml + + TextField { + anchors.centerIn: parent + width: parent.width + visible: nodeType === "NumberSource" + text: (delegateModel && delegateModel.number !== undefined) ? delegateModel.number.toString() : "0" + onEditingFinished: { + if (delegateModel) delegateModel.number = parseFloat(text) + } + color: "black" + background: Rectangle { color: "white" } + } + + Text { + anchors.centerIn: parent + visible: nodeType === "NumberDisplay" + text: (delegateModel && delegateModel.displayedText !== undefined) ? delegateModel.displayedText : "..." + color: "white" + font.pixelSize: 20 + } + + Text { + anchors.centerIn: parent + visible: nodeType === "Addition" + text: "+" + color: "white" + font.pixelSize: 40 + font.bold: true + } + } + } + } + } +} diff --git a/include/QtNodes/qml/ConnectionsListModel.hpp b/include/QtNodes/qml/ConnectionsListModel.hpp new file mode 100644 index 000000000..b073e97cd --- /dev/null +++ b/include/QtNodes/qml/ConnectionsListModel.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include "QtNodes/internal/Definitions.hpp" + +namespace QtNodes { + +class AbstractGraphModel; + +class ConnectionsListModel : public QAbstractListModel +{ + Q_OBJECT +public: + enum Role { + ConnectionIdRole = Qt::UserRole + 1, + SourceNodeIdRole, + SourcePortIndexRole, + DestNodeIdRole, + DestPortIndexRole + }; + + explicit ConnectionsListModel(std::shared_ptr graphModel, QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + +public Q_SLOTS: + void onConnectionCreated(ConnectionId connectionId); + void onConnectionDeleted(ConnectionId connectionId); + +private: + std::shared_ptr _graphModel; + std::vector _connections; +}; + +} // namespace QtNodes diff --git a/include/QtNodes/qml/NodesListModel.hpp b/include/QtNodes/qml/NodesListModel.hpp new file mode 100644 index 000000000..c5b4dba80 --- /dev/null +++ b/include/QtNodes/qml/NodesListModel.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include "QtNodes/internal/Definitions.hpp" + +namespace QtNodes { + +class AbstractGraphModel; + +class NodesListModel : public QAbstractListModel +{ + Q_OBJECT +public: + enum Role { + NodeIdRole = Qt::UserRole + 1, + TypeRole, + PositionRole, + CaptionRole, + InPortCountRole, + OutPortCountRole, + DelegateModelRole, + ResizableRole, + WidthRole, + HeightRole + }; + + explicit NodesListModel(std::shared_ptr graphModel, QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + Q_INVOKABLE bool moveNode(int nodeId, double x, double y); + +public Q_SLOTS: + void onNodeCreated(NodeId nodeId); + void onNodeDeleted(NodeId nodeId); + void onNodePositionUpdated(NodeId nodeId); + void onNodeUpdated(NodeId nodeId); + +private: + std::shared_ptr _graphModel; + std::vector _nodeIds; +}; + +} // namespace QtNodes diff --git a/include/QtNodes/qml/QuickGraphModel.hpp b/include/QtNodes/qml/QuickGraphModel.hpp new file mode 100644 index 000000000..c939c968a --- /dev/null +++ b/include/QtNodes/qml/QuickGraphModel.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +#include "NodesListModel.hpp" +#include "ConnectionsListModel.hpp" + +namespace QtNodes { + +class DataFlowGraphModel; +class NodeDelegateModelRegistry; + +class QuickGraphModel : public QObject +{ + Q_OBJECT + Q_PROPERTY(QtNodes::NodesListModel* nodes READ nodes CONSTANT) + Q_PROPERTY(QtNodes::ConnectionsListModel* connections READ connections CONSTANT) + +public: + explicit QuickGraphModel(QObject *parent = nullptr); + ~QuickGraphModel(); + + // Initialization with an existing registry + void setRegistry(std::shared_ptr registry); + + // Or just access the internal model + std::shared_ptr graphModel() const; + + NodesListModel* nodes() const; + ConnectionsListModel* connections() const; + + Q_INVOKABLE int addNode(QString const &nodeType); + Q_INVOKABLE bool removeNode(int nodeId); + + Q_INVOKABLE void addConnection(int outNodeId, int outPortIndex, int inNodeId, int inPortIndex); + Q_INVOKABLE void removeConnection(int outNodeId, int outPortIndex, int inNodeId, int inPortIndex); + +private: + std::shared_ptr _model; + NodesListModel* _nodesList; + ConnectionsListModel* _connectionsList; +}; + +} // namespace QtNodes diff --git a/resources/qml.qrc b/resources/qml.qrc new file mode 100644 index 000000000..81c2fdfbe --- /dev/null +++ b/resources/qml.qrc @@ -0,0 +1,7 @@ + + + qml/NodeGraph.qml + qml/Node.qml + qml/Connection.qml + + diff --git a/resources/qml/Connection.qml b/resources/qml/Connection.qml new file mode 100644 index 000000000..2e7a62d75 --- /dev/null +++ b/resources/qml/Connection.qml @@ -0,0 +1,47 @@ +import QtQuick 2.15 +import QtQuick.Shapes 1.15 + +Shape { + id: root + property var graph + property var modelData // The model roles are available directly in context, but passing 'model' helps sometimes. + + // Roles from ConnectionsListModel + // sourceNodeId, sourcePortIndex, destNodeId, destPortIndex + + property var sourceNode: graph.nodeItems[sourceNodeId] + property var destNode: graph.nodeItems[destNodeId] + + Connections { + target: graph + function onNodeRegistryChanged() { + sourceNode = graph.nodeItems[sourceNodeId] + destNode = graph.nodeItems[destNodeId] + } + } + + // 0 = In, 1 = Out. + // Source is Out (1), Dest is In (0). + property point startPos: sourceNode ? sourceNode.getPortPos(1, sourcePortIndex) : Qt.point(0,0) + property point endPos: destNode ? destNode.getPortPos(0, destPortIndex) : Qt.point(0,0) + + visible: sourceNode !== undefined && destNode !== undefined + + ShapePath { + strokeWidth: 3 + strokeColor: "black" + fillColor: "transparent" + + startX: root.startPos.x + startY: root.startPos.y + + PathCubic { + x: root.endPos.x + y: root.endPos.y + control1X: root.startPos.x + Math.abs(root.endPos.x - root.startPos.x) * 0.5 + control1Y: root.startPos.y + control2X: root.endPos.x - Math.abs(root.endPos.x - root.startPos.x) * 0.5 + control2Y: root.endPos.y + } + } +} diff --git a/resources/qml/Node.qml b/resources/qml/Node.qml new file mode 100644 index 000000000..be7957467 --- /dev/null +++ b/resources/qml/Node.qml @@ -0,0 +1,139 @@ +import QtQuick 2.15 + +Rectangle { + id: root + property var graph + property int nodeId + property string nodeType + property string caption + property int inPorts + property int outPorts + property var delegateModel // QObject* from C++ + property Component contentDelegate + + property real initialX + property real initialY + + property bool completed: false + + x: initialX + y: initialY + + width: 150 + height: Math.max(Math.max(inPorts, outPorts) * 20 + 40, 50) + + color: "#2d2d2d" + border.color: "black" + border.width: 2 + radius: 5 + + Component.onCompleted: { + completed = true + } + + DragHandler { + target: root + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 8 + text: caption + color: "#eeeeee" + font.bold: true + } + + Loader { + id: contentLoader + anchors.top: parent.top + anchors.topMargin: 35 + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width - 20 + height: parent.height - 50 + sourceComponent: contentDelegate + + // Pass properties to the loaded item + property var delegateModel: root.delegateModel + property string nodeType: root.nodeType + } + + // Input Ports + Column { + id: inPortsColumn + anchors.left: parent.left + anchors.top: parent.top + anchors.topMargin: 35 + anchors.leftMargin: -5 // Overlap edge + spacing: 10 + + Repeater { + id: inRepeater + model: inPorts + delegate: Rectangle { + width: 12; height: 12 + radius: 6 + color: "green" + border.color: "black" + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: parent.scale = 1.2 + onExited: parent.scale = 1.0 + onPressed: { + // Handle drop? In ports usually receive connections. + } + } + } + } + } + + // Output Ports + Column { + id: outPortsColumn + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: 35 + anchors.rightMargin: -5 + spacing: 10 + + Repeater { + id: outRepeater + model: outPorts + delegate: Rectangle { + width: 12; height: 12 + radius: 6 + color: "orange" + border.color: "black" + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: parent.scale = 1.2 + onExited: parent.scale = 1.0 + + onPressed: { + // Start dragging connection + // Notify graph + } + } + } + } + } + + function getPortPos(type, index) { + var repeater = (type === 0) ? inRepeater : outRepeater + var portItem = repeater.itemAt(index) + + if (portItem) { + // Map to the graph's canvas (parent's parent usually, but let's be safe) + // graph.contentItem is the Item inside Flickable. + // root is inside that Item. + return root.mapToItem(root.parent, + portItem.x + (type === 0 ? inPortsColumn.x : outPortsColumn.x) + portItem.width/2, + portItem.y + (type === 0 ? inPortsColumn.y : outPortsColumn.y) + portItem.height/2) + } + return Qt.point(x, y) + } +} diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml new file mode 100644 index 000000000..7dfc80344 --- /dev/null +++ b/resources/qml/NodeGraph.qml @@ -0,0 +1,147 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Shapes 1.15 +import QtNodes 1.0 + +Item { + id: root + property QuickGraphModel graphModel + + property var nodeItems: ({}) + property Component nodeContentDelegate // User provided content + + function registerNode(id, item) { + nodeItems[id] = item + // Trigger update for connections? + // Since nodeItems is a var, changes don't automatically trigger bindings unless we assign to a property. + // But connections will look up nodeItems. + nodeRegistryChanged() + } + + signal nodeRegistryChanged() + + // Temporary drafting connection + property point dragStart + property point dragCurrent + property bool isDragging: false + + Flickable { + id: flickable + anchors.fill: parent + contentWidth: 5000 + contentHeight: 5000 + + Item { + id: canvas + width: 5000 + height: 5000 + + // Background + Rectangle { + anchors.fill: parent + color: "#3c3c3c" + } + + // Grid + Shape { + anchors.fill: parent + ShapePath { + strokeWidth: 1 + strokeColor: "#505050" + fillColor: "transparent" + + // Vertical lines + startX: 0; startY: 0 + PathMultiline { + paths: { + var p = [] + for (var i = 0; i < 5000; i += 20) { + p.push(Qt.point(i, 0)) + p.push(Qt.point(i, 5000)) + } + return p + } + } + } + ShapePath { + strokeWidth: 1 + strokeColor: "#505050" + fillColor: "transparent" + + // Horizontal lines + startX: 0; startY: 0 + PathMultiline { + paths: { + var p = [] + for (var j = 0; j < 5000; j += 20) { + p.push(Qt.point(0, j)) + p.push(Qt.point(5000, j)) + } + return p + } + } + } + } + + // Connections + Repeater { + model: graphModel ? graphModel.connections : null + delegate: Connection { + graph: root + modelData: model + } + } + + // Nodes + Repeater { + model: graphModel ? graphModel.nodes : null + delegate: Node { + id: nodeDelegate + graph: root + + // Model Roles + nodeId: model.nodeId + nodeType: model.nodeType + initialX: model.position.x + initialY: model.position.y + caption: model.caption + inPorts: model.inPorts + outPorts: model.outPorts + delegateModel: model.model // The C++ QObject* + contentDelegate: root.nodeContentDelegate + + onXChanged: { + if (completed) graphModel.nodes.moveNode(nodeId, x, y) + } + onYChanged: { + if (completed) graphModel.nodes.moveNode(nodeId, x, y) + } + + Component.onCompleted: { + root.registerNode(nodeId, nodeDelegate) + } + } + } + + // Dragging Connection + Shape { + visible: root.isDragging + ShapePath { + strokeWidth: 2 + strokeColor: "orange" + fillColor: "transparent" + startX: root.dragStart.x + startY: root.dragStart.y + PathCubic { + x: root.dragCurrent.x + y: root.dragCurrent.y + control1X: root.dragStart.x + 50 + control1Y: root.dragStart.y + control2X: root.dragCurrent.x - 50 + control2Y: root.dragCurrent.y + } + } + } + } + } +} diff --git a/src/qml/ConnectionsListModel.cpp b/src/qml/ConnectionsListModel.cpp new file mode 100644 index 000000000..d1cc6f778 --- /dev/null +++ b/src/qml/ConnectionsListModel.cpp @@ -0,0 +1,98 @@ +#include "QtNodes/qml/ConnectionsListModel.hpp" + +#include "QtNodes/internal/AbstractGraphModel.hpp" + +namespace QtNodes { + +ConnectionsListModel::ConnectionsListModel(std::shared_ptr graphModel, QObject *parent) + : QAbstractListModel(parent) + , _graphModel(std::move(graphModel)) +{ + if (_graphModel) { + connect(_graphModel.get(), &AbstractGraphModel::connectionCreated, this, &ConnectionsListModel::onConnectionCreated); + connect(_graphModel.get(), &AbstractGraphModel::connectionDeleted, this, &ConnectionsListModel::onConnectionDeleted); + + // Initialize with existing connections + // Since there's no 'allConnectionIds' global accessor, we iterate nodes + // Wait, AbstractGraphModel doesn't have 'allConnectionIds()' without args? + // Checking AbstractGraphModel.hpp: + // virtual std::unordered_set allNodeIds() const = 0; + // virtual std::unordered_set allConnectionIds(NodeId const nodeId) const = 0; + // It does not have a global allConnectionIds. + + auto nodeIds = _graphModel->allNodeIds(); + for (auto nodeId : nodeIds) { + auto connections = _graphModel->allConnectionIds(nodeId); + for (auto& conn : connections) { + // Avoid duplicates. Since connections are directed (Out->In), + // we can just store them all, but 'allConnectionIds(nodeId)' returns + // connections attached to the node (both in and out). + // So we will see each connection twice. + // We should only add if this node is the output node (or input node). + // ConnectionId struct: outNodeId, outPortIndex, inNodeId, inPortIndex. + + if (conn.outNodeId == nodeId) { + _connections.push_back(conn); + } + } + } + } +} + +int ConnectionsListModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + return static_cast(_connections.size()); +} + +QVariant ConnectionsListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= static_cast(_connections.size())) + return QVariant(); + + const auto& conn = _connections[index.row()]; + + switch (role) { + case SourceNodeIdRole: + return QVariant::fromValue(static_cast(conn.outNodeId)); + case SourcePortIndexRole: + return QVariant::fromValue(static_cast(conn.outPortIndex)); + case DestNodeIdRole: + return QVariant::fromValue(static_cast(conn.inNodeId)); + case DestPortIndexRole: + return QVariant::fromValue(static_cast(conn.inPortIndex)); + } + + return QVariant(); +} + +QHash ConnectionsListModel::roleNames() const +{ + QHash roles; + roles[SourceNodeIdRole] = "sourceNodeId"; + roles[SourcePortIndexRole] = "sourcePortIndex"; + roles[DestNodeIdRole] = "destNodeId"; + roles[DestPortIndexRole] = "destPortIndex"; + return roles; +} + +void ConnectionsListModel::onConnectionCreated(ConnectionId connectionId) +{ + beginInsertRows(QModelIndex(), static_cast(_connections.size()), static_cast(_connections.size())); + _connections.push_back(connectionId); + endInsertRows(); +} + +void ConnectionsListModel::onConnectionDeleted(ConnectionId connectionId) +{ + auto it = std::find(_connections.begin(), _connections.end(), connectionId); + if (it != _connections.end()) { + int index = static_cast(std::distance(_connections.begin(), it)); + beginRemoveRows(QModelIndex(), index, index); + _connections.erase(it); + endRemoveRows(); + } +} + +} // namespace QtNodes diff --git a/src/qml/NodesListModel.cpp b/src/qml/NodesListModel.cpp new file mode 100644 index 000000000..6092ac12e --- /dev/null +++ b/src/qml/NodesListModel.cpp @@ -0,0 +1,133 @@ +#include "QtNodes/qml/NodesListModel.hpp" + +#include "QtNodes/internal/AbstractGraphModel.hpp" +#include "QtNodes/internal/DataFlowGraphModel.hpp" +#include "QtNodes/internal/NodeDelegateModel.hpp" + +namespace QtNodes { + +NodesListModel::NodesListModel(std::shared_ptr graphModel, QObject *parent) + : QAbstractListModel(parent) + , _graphModel(std::move(graphModel)) +{ + if (_graphModel) { + connect(_graphModel.get(), &AbstractGraphModel::nodeCreated, this, &NodesListModel::onNodeCreated); + connect(_graphModel.get(), &AbstractGraphModel::nodeDeleted, this, &NodesListModel::onNodeDeleted); + connect(_graphModel.get(), &AbstractGraphModel::nodePositionUpdated, this, &NodesListModel::onNodePositionUpdated); + connect(_graphModel.get(), &AbstractGraphModel::nodeUpdated, this, &NodesListModel::onNodeUpdated); + + // Initialize with existing nodes + auto ids = _graphModel->allNodeIds(); + _nodeIds.reserve(ids.size()); + for (auto id : ids) { + _nodeIds.push_back(id); + } + } +} + +int NodesListModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + return static_cast(_nodeIds.size()); +} + +QVariant NodesListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= static_cast(_nodeIds.size())) + return QVariant(); + + NodeId nodeId = _nodeIds[index.row()]; + + switch (role) { + case NodeIdRole: + return QVariant::fromValue(static_cast(nodeId)); + case TypeRole: + return _graphModel->nodeData(nodeId, NodeRole::Type); + case PositionRole: + return _graphModel->nodeData(nodeId, NodeRole::Position); + case CaptionRole: + return _graphModel->nodeData(nodeId, NodeRole::Caption); + case InPortCountRole: + return _graphModel->nodeData(nodeId, NodeRole::InPortCount); + case OutPortCountRole: + return _graphModel->nodeData(nodeId, NodeRole::OutPortCount); + case ResizableRole: + return bool(_graphModel->nodeFlags(nodeId) & NodeFlag::Resizable); + case WidthRole: + return _graphModel->nodeData(nodeId, NodeRole::Size).toSize().width(); + case HeightRole: + return _graphModel->nodeData(nodeId, NodeRole::Size).toSize().height(); + case DelegateModelRole: { + auto dfModel = std::dynamic_pointer_cast(_graphModel); + if (dfModel) { + auto model = dfModel->delegateModel(nodeId); + return QVariant::fromValue(model); + } + return QVariant(); + } + } + + return QVariant(); +} + +QHash NodesListModel::roleNames() const +{ + QHash roles; + roles[NodeIdRole] = "nodeId"; + roles[TypeRole] = "nodeType"; + roles[PositionRole] = "position"; + roles[CaptionRole] = "caption"; + roles[InPortCountRole] = "inPorts"; + roles[OutPortCountRole] = "outPorts"; + roles[DelegateModelRole] = "model"; + roles[ResizableRole] = "resizable"; + roles[WidthRole] = "width"; + roles[HeightRole] = "height"; + return roles; +} + +bool NodesListModel::moveNode(int nodeId, double x, double y) +{ + return _graphModel->setNodeData(static_cast(nodeId), NodeRole::Position, QPointF(x, y)); +} + +void NodesListModel::onNodeCreated(NodeId nodeId) +{ + beginInsertRows(QModelIndex(), static_cast(_nodeIds.size()), static_cast(_nodeIds.size())); + _nodeIds.push_back(nodeId); + endInsertRows(); +} + +void NodesListModel::onNodeDeleted(NodeId nodeId) +{ + auto it = std::find(_nodeIds.begin(), _nodeIds.end(), nodeId); + if (it != _nodeIds.end()) { + int index = static_cast(std::distance(_nodeIds.begin(), it)); + beginRemoveRows(QModelIndex(), index, index); + _nodeIds.erase(it); + endRemoveRows(); + } +} + +void NodesListModel::onNodePositionUpdated(NodeId nodeId) +{ + auto it = std::find(_nodeIds.begin(), _nodeIds.end(), nodeId); + if (it != _nodeIds.end()) { + int index = static_cast(std::distance(_nodeIds.begin(), it)); + QModelIndex idx = createIndex(index, 0); + Q_EMIT dataChanged(idx, idx, {PositionRole}); + } +} + +void NodesListModel::onNodeUpdated(NodeId nodeId) +{ + auto it = std::find(_nodeIds.begin(), _nodeIds.end(), nodeId); + if (it != _nodeIds.end()) { + int index = static_cast(std::distance(_nodeIds.begin(), it)); + QModelIndex idx = createIndex(index, 0); + Q_EMIT dataChanged(idx, idx); // Update all roles + } +} + +} // namespace QtNodes diff --git a/src/qml/QuickGraphModel.cpp b/src/qml/QuickGraphModel.cpp new file mode 100644 index 000000000..34ea815fa --- /dev/null +++ b/src/qml/QuickGraphModel.cpp @@ -0,0 +1,91 @@ +#include "QtNodes/qml/QuickGraphModel.hpp" + +#include "QtNodes/internal/DataFlowGraphModel.hpp" +#include "QtNodes/internal/NodeDelegateModelRegistry.hpp" + +namespace QtNodes { + +QuickGraphModel::QuickGraphModel(QObject *parent) + : QObject(parent) + , _nodesList(nullptr) + , _connectionsList(nullptr) +{ +} + +QuickGraphModel::~QuickGraphModel() +{ + // delete models managed by QML ownership usually, but here they are children of this? + // Actually QAbstractListModels are QObjects, so if we parent them, they auto delete. +} + +void QuickGraphModel::setRegistry(std::shared_ptr registry) +{ + _model = std::make_shared(registry); + + // Re-create list models + if (_nodesList) _nodesList->deleteLater(); + if (_connectionsList) _connectionsList->deleteLater(); + + _nodesList = new NodesListModel(_model, this); + _connectionsList = new ConnectionsListModel(_model, this); + + // Notify QML that properties changed? + // Since they are CONSTANT in my declaration, QML assumes they don't change. + // Ideally I should have a NOTIFY signal, but for simplicity I assume setRegistry is called before QML uses it, + // or I should update the property declaration. + // However, since we are constructor-injecting or property-injecting in C++, + // it's safer to make them NOTIFY. But for now, let's stick to CONSTANT and assume setup happens at startup. +} + +std::shared_ptr QuickGraphModel::graphModel() const +{ + return _model; +} + +NodesListModel* QuickGraphModel::nodes() const +{ + return _nodesList; +} + +ConnectionsListModel* QuickGraphModel::connections() const +{ + return _connectionsList; +} + +int QuickGraphModel::addNode(QString const &nodeType) +{ + if (!_model) return -1; + return static_cast(_model->addNode(nodeType)); +} + +bool QuickGraphModel::removeNode(int nodeId) +{ + if (!_model) return false; + return _model->deleteNode(static_cast(nodeId)); +} + +void QuickGraphModel::addConnection(int outNodeId, int outPortIndex, int inNodeId, int inPortIndex) +{ + if (!_model) return; + ConnectionId connId; + connId.outNodeId = static_cast(outNodeId); + connId.outPortIndex = static_cast(outPortIndex); + connId.inNodeId = static_cast(inNodeId); + connId.inPortIndex = static_cast(inPortIndex); + + _model->addConnection(connId); +} + +void QuickGraphModel::removeConnection(int outNodeId, int outPortIndex, int inNodeId, int inPortIndex) +{ + if (!_model) return; + ConnectionId connId; + connId.outNodeId = static_cast(outNodeId); + connId.outPortIndex = static_cast(outPortIndex); + connId.inNodeId = static_cast(inNodeId); + connId.inPortIndex = static_cast(inPortIndex); + + _model->deleteConnection(connId); +} + +} // namespace QtNodes From d4d83f79dc14dbb093494422a48f0552a027ddeb Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Sun, 23 Nov 2025 21:10:48 -0300 Subject: [PATCH 02/33] nodes movment --- CMakeLists.txt | 2 +- examples/qml_calculator/CMakeLists.txt | 1 + examples/qml_calculator/main.cpp | 2 ++ resources/qml/Connection.qml | 8 +++++--- resources/qml/NodeGraph.qml | 21 ++++++++++++--------- src/qml/NodesListModel.cpp | 2 +- 6 files changed, 22 insertions(+), 14 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index f369c497d..2c5cc4eb0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,7 +58,7 @@ endif() find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets Gui OpenGL) if(BUILD_QML) - find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Quick Qml) + find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Quick Qml QuickControls2) if(NOT Qt${QT_VERSION_MAJOR}Quick_FOUND) message(WARNING "Qt Quick not found, QML support disabled") set(BUILD_QML OFF) diff --git a/examples/qml_calculator/CMakeLists.txt b/examples/qml_calculator/CMakeLists.txt index 025f0d8f9..4251de40f 100644 --- a/examples/qml_calculator/CMakeLists.txt +++ b/examples/qml_calculator/CMakeLists.txt @@ -13,6 +13,7 @@ target_link_libraries(${TARGET_NAME} PRIVATE QtNodes::QtNodes Qt${QT_VERSION_MAJOR}::Quick + Qt${QT_VERSION_MAJOR}::QuickControls2 Qt${QT_VERSION_MAJOR}::Qml Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Gui diff --git a/examples/qml_calculator/main.cpp b/examples/qml_calculator/main.cpp index ccfed2118..fb3e6395c 100644 --- a/examples/qml_calculator/main.cpp +++ b/examples/qml_calculator/main.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include @@ -25,6 +26,7 @@ static std::shared_ptr registerDataModels() int main(int argc, char *argv[]) { + QQuickStyle::setStyle("Fusion"); QGuiApplication app(argc, argv); qmlRegisterType("QtNodes", 1, 0, "QuickGraphModel"); diff --git a/resources/qml/Connection.qml b/resources/qml/Connection.qml index 2e7a62d75..250583cfb 100644 --- a/resources/qml/Connection.qml +++ b/resources/qml/Connection.qml @@ -4,10 +4,12 @@ import QtQuick.Shapes 1.15 Shape { id: root property var graph - property var modelData // The model roles are available directly in context, but passing 'model' helps sometimes. - // Roles from ConnectionsListModel - // sourceNodeId, sourcePortIndex, destNodeId, destPortIndex + // Roles from ConnectionsListModel are expected to be set on this item by the Repeater + property int sourceNodeId: -1 + property int sourcePortIndex: -1 + property int destNodeId: -1 + property int destPortIndex: -1 property var sourceNode: graph.nodeItems[sourceNodeId] property var destNode: graph.nodeItems[destNodeId] diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index 7dfc80344..4545d725d 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -88,7 +88,10 @@ Item { model: graphModel ? graphModel.connections : null delegate: Connection { graph: root - modelData: model + property int sourceNodeId: model.sourceNodeId + property int sourcePortIndex: model.sourcePortIndex + property int destNodeId: model.destNodeId + property int destPortIndex: model.destPortIndex } } @@ -100,14 +103,14 @@ Item { graph: root // Model Roles - nodeId: model.nodeId - nodeType: model.nodeType - initialX: model.position.x - initialY: model.position.y - caption: model.caption - inPorts: model.inPorts - outPorts: model.outPorts - delegateModel: model.model // The C++ QObject* + property int nodeId: model.nodeId + property string nodeType: model.nodeType + property real initialX: model.position.x + property real initialY: model.position.y + property string caption: model.caption + property int inPorts: model.inPorts + property int outPorts: model.outPorts + property var delegateModel: model.delegateModel // The C++ QObject* contentDelegate: root.nodeContentDelegate onXChanged: { diff --git a/src/qml/NodesListModel.cpp b/src/qml/NodesListModel.cpp index 6092ac12e..d10401a72 100644 --- a/src/qml/NodesListModel.cpp +++ b/src/qml/NodesListModel.cpp @@ -80,7 +80,7 @@ QHash NodesListModel::roleNames() const roles[CaptionRole] = "caption"; roles[InPortCountRole] = "inPorts"; roles[OutPortCountRole] = "outPorts"; - roles[DelegateModelRole] = "model"; + roles[DelegateModelRole] = "delegateModel"; roles[ResizableRole] = "resizable"; roles[WidthRole] = "width"; roles[HeightRole] = "height"; From e51abaa7c857a55e2607b5e5449511c677fd890c Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Sun, 23 Nov 2025 21:18:03 -0300 Subject: [PATCH 03/33] zooming --- examples/qml_calculator/main.qml | 3 +- resources/qml/Node.qml | 19 ++++- resources/qml/NodeGraph.qml | 128 ++++++++++++++++++------------- 3 files changed, 91 insertions(+), 59 deletions(-) diff --git a/examples/qml_calculator/main.qml b/examples/qml_calculator/main.qml index e44ebba53..7d717cb72 100644 --- a/examples/qml_calculator/main.qml +++ b/examples/qml_calculator/main.qml @@ -31,7 +31,8 @@ Window { nodeContentDelegate: Component { Item { - // delegateModel and nodeType are provided by the Loader in Node.qml + property var delegateModel + property string nodeType TextField { anchors.centerIn: parent diff --git a/resources/qml/Node.qml b/resources/qml/Node.qml index be7957467..d79681719 100644 --- a/resources/qml/Node.qml +++ b/resources/qml/Node.qml @@ -53,9 +53,22 @@ Rectangle { height: parent.height - 50 sourceComponent: contentDelegate - // Pass properties to the loaded item - property var delegateModel: root.delegateModel - property string nodeType: root.nodeType + onLoaded: { + if (item) { + item.delegateModel = Qt.binding(function(){ return root.delegateModel }) + item.nodeType = Qt.binding(function(){ return root.nodeType }) + } + } + + Connections { + target: root + function onDelegateModelChanged() { + if (contentLoader.item) contentLoader.item.delegateModel = root.delegateModel + } + function onNodeTypeChanged() { + if (contentLoader.item) contentLoader.item.nodeType = root.nodeType + } + } } // Input Ports diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index 4545d725d..943173cdd 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -12,76 +12,63 @@ Item { function registerNode(id, item) { nodeItems[id] = item - // Trigger update for connections? - // Since nodeItems is a var, changes don't automatically trigger bindings unless we assign to a property. - // But connections will look up nodeItems. nodeRegistryChanged() } signal nodeRegistryChanged() + // Zoom and Pan + property real zoomLevel: 1.0 + property point panOffset: Qt.point(0, 0) + // Temporary drafting connection property point dragStart property point dragCurrent property bool isDragging: false - Flickable { - id: flickable + Rectangle { anchors.fill: parent - contentWidth: 5000 - contentHeight: 5000 - - Item { - id: canvas - width: 5000 - height: 5000 + color: "#2b2b2b" + clip: true - // Background - Rectangle { - anchors.fill: parent - color: "#3c3c3c" - } - - // Grid - Shape { - anchors.fill: parent - ShapePath { - strokeWidth: 1 - strokeColor: "#505050" - fillColor: "transparent" + // Grid Shader + ShaderEffect { + anchors.fill: parent + property real zoom: root.zoomLevel + property point offset: root.panOffset + property size size: Qt.size(width, height) + + fragmentShader: " + varying highp vec2 qt_TexCoord0; + uniform highp float zoom; + uniform highp vec2 offset; + uniform highp vec2 size; + + void main() { + lowp vec2 coord = (qt_TexCoord0 * size - offset) / zoom; + lowp vec2 grid = abs(fract(coord / 20.0 - 0.5) - 0.5) / fwidth(coord / 20.0); + lowp float line = min(grid.x, grid.y); + lowp float alpha = 1.0 - min(line, 1.0); - // Vertical lines - startX: 0; startY: 0 - PathMultiline { - paths: { - var p = [] - for (var i = 0; i < 5000; i += 20) { - p.push(Qt.point(i, 0)) - p.push(Qt.point(i, 5000)) - } - return p - } - } - } - ShapePath { - strokeWidth: 1 - strokeColor: "#505050" - fillColor: "transparent" + // Major grid lines + lowp vec2 grid2 = abs(fract(coord / 100.0 - 0.5) - 0.5) / fwidth(coord / 100.0); + lowp float line2 = min(grid2.x, grid2.y); + lowp float alpha2 = 1.0 - min(line2, 1.0); - // Horizontal lines - startX: 0; startY: 0 - PathMultiline { - paths: { - var p = [] - for (var j = 0; j < 5000; j += 20) { - p.push(Qt.point(0, j)) - p.push(Qt.point(5000, j)) - } - return p - } - } + gl_FragColor = vec4(0.6, 0.6, 0.6, max(alpha * 0.1, alpha2 * 0.3)); } - } + " + } + + // Graph Content Area + Item { + id: canvas + width: 5000 // Virtual size, but we rely on infinite panning logic visually + height: 5000 + x: root.panOffset.x + y: root.panOffset.y + scale: root.zoomLevel + transformOrigin: Item.TopLeft // Connections Repeater { @@ -146,5 +133,36 @@ Item { } } } + + // Input Handler for Pan/Zoom + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.MiddleButton | Qt.LeftButton + property point lastPos + + onPressed: { + lastPos = Qt.point(mouse.x, mouse.y) + } + + onPositionChanged: { + if (pressedButtons & Qt.MiddleButton || (pressedButtons & Qt.LeftButton && (mouse.modifiers & Qt.AltModifier))) { + var delta = Qt.point(mouse.x - lastPos.x, mouse.y - lastPos.y) + root.panOffset = Qt.point(root.panOffset.x + delta.x, root.panOffset.y + delta.y) + lastPos = Qt.point(mouse.x, mouse.y) + } + } + + onWheel: { + var zoomFactor = 1.1 + if (wheel.angleDelta.y < 0) { + zoomLevel /= zoomFactor + } else { + zoomLevel *= zoomFactor + } + // Clamp zoom + if (zoomLevel < 0.1) zoomLevel = 0.1 + if (zoomLevel > 5.0) zoomLevel = 5.0 + } + } } } From 50d20ffb8a3bf9e8dc1d4474b3d0788d3d0de802 Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Sun, 23 Nov 2025 21:21:45 -0300 Subject: [PATCH 04/33] world grid --- resources/qml/NodeGraph.qml | 90 ++++++++++++++++++++++++++++--------- resources/shaders/grid.frag | 28 ++++++++++++ 2 files changed, 98 insertions(+), 20 deletions(-) create mode 100644 resources/shaders/grid.frag diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index 943173cdd..1b0bf0612 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -38,26 +38,76 @@ Item { property point offset: root.panOffset property size size: Qt.size(width, height) - fragmentShader: " - varying highp vec2 qt_TexCoord0; - uniform highp float zoom; - uniform highp vec2 offset; - uniform highp vec2 size; - - void main() { - lowp vec2 coord = (qt_TexCoord0 * size - offset) / zoom; - lowp vec2 grid = abs(fract(coord / 20.0 - 0.5) - 0.5) / fwidth(coord / 20.0); - lowp float line = min(grid.x, grid.y); - lowp float alpha = 1.0 - min(line, 1.0); - - // Major grid lines - lowp vec2 grid2 = abs(fract(coord / 100.0 - 0.5) - 0.5) / fwidth(coord / 100.0); - lowp float line2 = min(grid2.x, grid2.y); - lowp float alpha2 = 1.0 - min(line2, 1.0); - - gl_FragColor = vec4(0.6, 0.6, 0.6, max(alpha * 0.1, alpha2 * 0.3)); - } - " + // In Qt6, we'd normally use .qsb files. + // But to keep it simple and cross-version compatible (Qt5/Qt6), + // let's revert to the standard Shape-based grid for now, + // as inline shaders are deprecated/removed in Qt6's RHI. + // Or we can use a Canvas which is easier than Shapes for infinite grids. + visible: false + } + + // Canvas Grid + Canvas { + id: gridCanvas + anchors.fill: parent + property real zoom: root.zoomLevel + property point offset: root.panOffset + + onZoomChanged: requestPaint() + onOffsetChanged: requestPaint() + + onPaint: { + var ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + + ctx.strokeStyle = "#505050" + ctx.lineWidth = 1 + + var gridSize = 20 * zoom + var majorGridSize = 100 * zoom + + var startX = (offset.x % gridSize) + var startY = (offset.y % gridSize) + + if (startX < 0) startX += gridSize + if (startY < 0) startY += gridSize + + ctx.beginPath() + + // Vertical lines + for (var x = startX; x < width; x += gridSize) { + ctx.moveTo(x, 0) + ctx.lineTo(x, height) + } + + // Horizontal lines + for (var y = startY; y < height; y += gridSize) { + ctx.moveTo(0, y) + ctx.lineTo(width, y) + } + + ctx.stroke() + + // Major lines + /* + ctx.strokeStyle = "#707070" + ctx.beginPath() + var mStartX = (offset.x % majorGridSize) + var mStartY = (offset.y % majorGridSize) + if (mStartX < 0) mStartX += majorGridSize + if (mStartY < 0) mStartY += majorGridSize + + for (var mx = mStartX; mx < width; mx += majorGridSize) { + ctx.moveTo(mx, 0) + ctx.lineTo(mx, height) + } + for (var my = mStartY; my < height; my += majorGridSize) { + ctx.moveTo(0, my) + ctx.lineTo(width, my) + } + ctx.stroke() + */ + } } // Graph Content Area diff --git a/resources/shaders/grid.frag b/resources/shaders/grid.frag new file mode 100644 index 000000000..8ba28d420 --- /dev/null +++ b/resources/shaders/grid.frag @@ -0,0 +1,28 @@ +#version 440 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float opacity; + float zoom; + vec2 offset; + vec2 size; +}; + +void main() { + vec2 coord = (qt_TexCoord0 * size - offset) / zoom; + + // Anti-aliased grid + vec2 grid = abs(fract(coord / 20.0 - 0.5) - 0.5) / fwidth(coord / 20.0); + float line = min(grid.x, grid.y); + float alpha = 1.0 - min(line, 1.0); + + // Major grid lines + vec2 grid2 = abs(fract(coord / 100.0 - 0.5) - 0.5) / fwidth(coord / 100.0); + float line2 = min(grid2.x, grid2.y); + float alpha2 = 1.0 - min(line2, 1.0); + + fragColor = vec4(0.6, 0.6, 0.6, max(alpha * 0.1, alpha2 * 0.3) * opacity); +} From 5cf1a73efdc3e12eb6226d0ce1337110d0317a9b Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Sun, 23 Nov 2025 21:30:31 -0300 Subject: [PATCH 05/33] better grids --- resources/qml/NodeGraph.qml | 45 ++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index 1b0bf0612..fd2325496 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -60,7 +60,6 @@ Item { var ctx = getContext("2d") ctx.clearRect(0, 0, width, height) - ctx.strokeStyle = "#505050" ctx.lineWidth = 1 var gridSize = 20 * zoom @@ -72,6 +71,8 @@ Item { if (startX < 0) startX += gridSize if (startY < 0) startY += gridSize + // Minor lines + ctx.strokeStyle = "#353535" ctx.beginPath() // Vertical lines @@ -89,8 +90,7 @@ Item { ctx.stroke() // Major lines - /* - ctx.strokeStyle = "#707070" + ctx.strokeStyle = "#151515" ctx.beginPath() var mStartX = (offset.x % majorGridSize) var mStartY = (offset.y % majorGridSize) @@ -106,7 +106,6 @@ Item { ctx.lineTo(width, my) } ctx.stroke() - */ } } @@ -120,21 +119,25 @@ Item { scale: root.zoomLevel transformOrigin: Item.TopLeft - // Connections - Repeater { - model: graphModel ? graphModel.connections : null - delegate: Connection { - graph: root - property int sourceNodeId: model.sourceNodeId - property int sourcePortIndex: model.sourcePortIndex - property int destNodeId: model.destNodeId - property int destPortIndex: model.destPortIndex - } - } + // Connections + property var graphConnections: graphModel ? graphModel.connections : null + + Repeater { + model: graphConnections + delegate: Connection { + graph: root + property int sourceNodeId: model.sourceNodeId + property int sourcePortIndex: model.sourcePortIndex + property int destNodeId: model.destNodeId + property int destPortIndex: model.destPortIndex + } + } - // Nodes - Repeater { - model: graphModel ? graphModel.nodes : null + // Nodes + property var graphNodes: graphModel ? graphModel.nodes : null + + Repeater { + model: graphNodes delegate: Node { id: nodeDelegate graph: root @@ -190,11 +193,11 @@ Item { acceptedButtons: Qt.MiddleButton | Qt.LeftButton property point lastPos - onPressed: { + onPressed: (mouse) => { lastPos = Qt.point(mouse.x, mouse.y) } - onPositionChanged: { + onPositionChanged: (mouse) => { if (pressedButtons & Qt.MiddleButton || (pressedButtons & Qt.LeftButton && (mouse.modifiers & Qt.AltModifier))) { var delta = Qt.point(mouse.x - lastPos.x, mouse.y - lastPos.y) root.panOffset = Qt.point(root.panOffset.x + delta.x, root.panOffset.y + delta.y) @@ -202,7 +205,7 @@ Item { } } - onWheel: { + onWheel: (wheel) => { var zoomFactor = 1.1 if (wheel.angleDelta.y < 0) { zoomLevel /= zoomFactor From 6a5c86b9358855f815662bd1615b0e2e427fe88f Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Sun, 23 Nov 2025 21:35:04 -0300 Subject: [PATCH 06/33] fix nodes --- resources/qml/Node.qml | 1 + resources/qml/NodeGraph.qml | 14 +++++--------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/resources/qml/Node.qml b/resources/qml/Node.qml index d79681719..04b173e46 100644 --- a/resources/qml/Node.qml +++ b/resources/qml/Node.qml @@ -55,6 +55,7 @@ Rectangle { onLoaded: { if (item) { + // Use explicit binding objects to ensure updates propagate item.delegateModel = Qt.binding(function(){ return root.delegateModel }) item.nodeType = Qt.binding(function(){ return root.nodeType }) } diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index fd2325496..ebd740288 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -120,10 +120,8 @@ Item { transformOrigin: Item.TopLeft // Connections - property var graphConnections: graphModel ? graphModel.connections : null - Repeater { - model: graphConnections + model: graphModel ? graphModel.connections : null delegate: Connection { graph: root property int sourceNodeId: model.sourceNodeId @@ -134,13 +132,11 @@ Item { } // Nodes - property var graphNodes: graphModel ? graphModel.nodes : null - Repeater { - model: graphNodes - delegate: Node { - id: nodeDelegate - graph: root + model: graphModel ? graphModel.nodes : null + delegate: Node { + id: nodeDelegate + graph: root // Model Roles property int nodeId: model.nodeId From 4676d4c98d0f09e99c10c9d4da17adbcf443400c Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Sun, 23 Nov 2025 21:53:51 -0300 Subject: [PATCH 07/33] fix errors logs --- resources/qml/Node.qml | 45 +++++++++++++++++++++++------ resources/qml/NodeGraph.qml | 57 ++++++++++++++++++++++++++++++++----- 2 files changed, 86 insertions(+), 16 deletions(-) diff --git a/resources/qml/Node.qml b/resources/qml/Node.qml index 04b173e46..345619883 100644 --- a/resources/qml/Node.qml +++ b/resources/qml/Node.qml @@ -93,10 +93,24 @@ Rectangle { MouseArea { anchors.fill: parent hoverEnabled: true - onEntered: parent.scale = 1.2 - onExited: parent.scale = 1.0 - onPressed: { - // Handle drop? In ports usually receive connections. + onEntered: { + parent.scale = 1.2 + graph.setActivePort({nodeId: root.nodeId, portType: 0, portIndex: index}) + } + onExited: { + parent.scale = 1.0 + graph.setActivePort(null) + } + onPressed: (mouse) => { + var pos = root.mapToItem(graph.canvas, x + parent.x + inPortsColumn.x + width/2, y + parent.y + inPortsColumn.y + height/2) + graph.startDraftConnection(root.nodeId, 0, index, pos) + } + onPositionChanged: (mouse) => { + var pos = root.mapToItem(graph.canvas, mouse.x + x + parent.x + inPortsColumn.x, mouse.y + y + parent.y + inPortsColumn.y) + graph.updateDraftConnection(pos) + } + onReleased: { + graph.endDraftConnection() } } } @@ -124,12 +138,25 @@ Rectangle { MouseArea { anchors.fill: parent hoverEnabled: true - onEntered: parent.scale = 1.2 - onExited: parent.scale = 1.0 + onEntered: { + parent.scale = 1.2 + graph.setActivePort({nodeId: root.nodeId, portType: 1, portIndex: index}) + } + onExited: { + parent.scale = 1.0 + graph.setActivePort(null) + } - onPressed: { - // Start dragging connection - // Notify graph + onPressed: (mouse) => { + var pos = root.mapToItem(graph.canvas, x + parent.x + outPortsColumn.x + width/2, y + parent.y + outPortsColumn.y + height/2) + graph.startDraftConnection(root.nodeId, 1, index, pos) + } + onPositionChanged: (mouse) => { + var pos = root.mapToItem(graph.canvas, mouse.x + x + parent.x + outPortsColumn.x, mouse.y + y + parent.y + outPortsColumn.y) + graph.updateDraftConnection(pos) + } + onReleased: { + graph.endDraftConnection() } } } diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index ebd740288..ca2ae6210 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -21,10 +21,50 @@ Item { property real zoomLevel: 1.0 property point panOffset: Qt.point(0, 0) + // Port dragging + property var activePort: null + // Temporary drafting connection - property point dragStart - property point dragCurrent + property point dragStart: Qt.point(0, 0) + property point dragCurrent: Qt.point(0, 0) property bool isDragging: false + + function startDraftConnection(nodeId, portType, portIndex, pos) { + dragStart = pos + dragCurrent = pos + isDragging = true + activeConnectionStart = {nodeId: nodeId, portType: portType, portIndex: portIndex} + } + + property var activeConnectionStart: null + + function updateDraftConnection(pos) { + dragCurrent = pos + } + + function endDraftConnection() { + isDragging = false + if (activePort && activeConnectionStart) { + // Check if connecting Out -> In or In -> Out + var start = activeConnectionStart + var end = activePort + + // We only allow Out -> In connection creation in this simple logic + // If drag started from Out (1) and ended at In (0) + if (start.portType === 1 && end.portType === 0) { + graphModel.addConnection(start.nodeId, start.portIndex, end.nodeId, end.portIndex) + } + // If drag started from In (0) and ended at Out (1) - usually we drag from source to dest + else if (start.portType === 0 && end.portType === 1) { + graphModel.addConnection(end.nodeId, end.portIndex, start.nodeId, start.portIndex) + } + } + activeConnectionStart = null + } + + function setActivePort(portInfo) { + activePort = portInfo + } Rectangle { anchors.fill: parent @@ -32,6 +72,7 @@ Item { clip: true // Grid Shader + /* ShaderEffect { anchors.fill: parent property real zoom: root.zoomLevel @@ -45,6 +86,7 @@ Item { // Or we can use a Canvas which is easier than Shapes for infinite grids. visible: false } + */ // Canvas Grid Canvas { @@ -124,10 +166,10 @@ Item { model: graphModel ? graphModel.connections : null delegate: Connection { graph: root - property int sourceNodeId: model.sourceNodeId - property int sourcePortIndex: model.sourcePortIndex - property int destNodeId: model.destNodeId - property int destPortIndex: model.destPortIndex + sourceNodeId: model.sourceNodeId + sourcePortIndex: model.sourcePortIndex + destNodeId: model.destNodeId + destPortIndex: model.destPortIndex } } @@ -157,6 +199,7 @@ Item { } Component.onCompleted: { + console.log("Node created. ID:", nodeId, "Caption:", caption, "In:", inPorts, "Out:", outPorts) root.registerNode(nodeId, nodeDelegate) } } @@ -187,7 +230,7 @@ Item { MouseArea { anchors.fill: parent acceptedButtons: Qt.MiddleButton | Qt.LeftButton - property point lastPos + property point lastPos: Qt.point(0, 0) onPressed: (mouse) => { lastPos = Qt.point(mouse.x, mouse.y) From 26dc5e3622b331eb0c5bdb946e686f44c7a449f1 Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Sun, 23 Nov 2025 22:01:03 -0300 Subject: [PATCH 08/33] nodes connections --- resources/qml/Connection.qml | 8 ++++---- resources/qml/NodeGraph.qml | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/resources/qml/Connection.qml b/resources/qml/Connection.qml index 250583cfb..bf03d49c8 100644 --- a/resources/qml/Connection.qml +++ b/resources/qml/Connection.qml @@ -24,14 +24,14 @@ Shape { // 0 = In, 1 = Out. // Source is Out (1), Dest is In (0). - property point startPos: sourceNode ? sourceNode.getPortPos(1, sourcePortIndex) : Qt.point(0,0) - property point endPos: destNode ? destNode.getPortPos(0, destPortIndex) : Qt.point(0,0) + property point startPos: (sourceNode && sourceNode.completed) ? sourceNode.getPortPos(1, sourcePortIndex) : Qt.point(0,0) + property point endPos: (destNode && destNode.completed) ? destNode.getPortPos(0, destPortIndex) : Qt.point(0,0) - visible: sourceNode !== undefined && destNode !== undefined + visible: sourceNode !== undefined && destNode !== undefined && sourceNode.completed && destNode.completed ShapePath { strokeWidth: 3 - strokeColor: "black" + strokeColor: "#eeeeee" fillColor: "transparent" startX: root.startPos.x diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index ca2ae6210..cd0226d72 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -181,14 +181,14 @@ Item { graph: root // Model Roles - property int nodeId: model.nodeId - property string nodeType: model.nodeType - property real initialX: model.position.x - property real initialY: model.position.y - property string caption: model.caption - property int inPorts: model.inPorts - property int outPorts: model.outPorts - property var delegateModel: model.delegateModel // The C++ QObject* + nodeId: model.nodeId + nodeType: model.nodeType + initialX: model.position.x + initialY: model.position.y + caption: model.caption + inPorts: model.inPorts + outPorts: model.outPorts + delegateModel: model.delegateModel // The C++ QObject* contentDelegate: root.nodeContentDelegate onXChanged: { From 290b0d0986d04a790e2c9330d59bb1ba729a736b Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Sun, 23 Nov 2025 22:21:36 -0300 Subject: [PATCH 09/33] wip connections --- resources/qml/NodeGraph.qml | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index cd0226d72..d1da29a5e 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -205,26 +205,26 @@ Item { } } - // Dragging Connection - Shape { - visible: root.isDragging - ShapePath { - strokeWidth: 2 - strokeColor: "orange" - fillColor: "transparent" - startX: root.dragStart.x - startY: root.dragStart.y - PathCubic { - x: root.dragCurrent.x - y: root.dragCurrent.y - control1X: root.dragStart.x + 50 - control1Y: root.dragStart.y - control2X: root.dragCurrent.x - 50 - control2Y: root.dragCurrent.y - } - } + // Dragging Connection + Shape { + visible: root.isDragging + ShapePath { + strokeWidth: 2 + strokeColor: "orange" + fillColor: "transparent" + startX: root.dragStart.x + startY: root.dragStart.y + PathCubic { + x: root.dragCurrent.x + y: root.dragCurrent.y + control1X: root.dragStart.x + Math.abs(root.dragCurrent.x - root.dragStart.x) * 0.5 + control1Y: root.dragStart.y + control2X: root.dragCurrent.x - Math.abs(root.dragCurrent.x - root.dragStart.x) * 0.5 + control2Y: root.dragCurrent.y } } + } + } // Input Handler for Pan/Zoom MouseArea { From 37479c8eb0b4ec3bcf3cbef8a8e3bcc4c284958f Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Sun, 23 Nov 2025 22:25:10 -0300 Subject: [PATCH 10/33] lines showing --- examples/qml_calculator/main.qml | 21 ++++++++++ resources/qml/NodeGraph.qml | 66 ++++++++++++++++---------------- 2 files changed, 54 insertions(+), 33 deletions(-) diff --git a/examples/qml_calculator/main.qml b/examples/qml_calculator/main.qml index 7d717cb72..f69f4c206 100644 --- a/examples/qml_calculator/main.qml +++ b/examples/qml_calculator/main.qml @@ -29,6 +29,27 @@ Window { height: parent.height - 40 graphModel: model + Component.onCompleted: { + var n1 = model.addNode("NumberSource") + var n2 = model.addNode("NumberSource") + var n3 = model.addNode("Addition") + var n4 = model.addNode("NumberDisplay") + + if (n1 >= 0 && n2 >= 0 && n3 >= 0 && n4 >= 0) { + model.nodes.moveNode(n1, 100, 100) + model.nodes.moveNode(n2, 100, 250) + model.nodes.moveNode(n3, 400, 175) + model.nodes.moveNode(n4, 700, 175) + + // Connect Source 1 to Addition In 0 + model.addConnection(n1, 0, n3, 0) + // Connect Source 2 to Addition In 1 + model.addConnection(n2, 0, n3, 1) + // Connect Addition Out 0 to Display In 0 + model.addConnection(n3, 0, n4, 0) + } + } + nodeContentDelegate: Component { Item { property var delegateModel diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index d1da29a5e..b189ef253 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -71,6 +71,37 @@ Item { color: "#2b2b2b" clip: true + // Input Handler for Pan/Zoom + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.MiddleButton | Qt.LeftButton + property point lastPos: Qt.point(0, 0) + + onPressed: (mouse) => { + lastPos = Qt.point(mouse.x, mouse.y) + } + + onPositionChanged: (mouse) => { + if (pressedButtons & Qt.MiddleButton || (pressedButtons & Qt.LeftButton && (mouse.modifiers & Qt.AltModifier))) { + var delta = Qt.point(mouse.x - lastPos.x, mouse.y - lastPos.y) + root.panOffset = Qt.point(root.panOffset.x + delta.x, root.panOffset.y + delta.y) + lastPos = Qt.point(mouse.x, mouse.y) + } + } + + onWheel: (wheel) => { + var zoomFactor = 1.1 + if (wheel.angleDelta.y < 0) { + zoomLevel /= zoomFactor + } else { + zoomLevel *= zoomFactor + } + // Clamp zoom + if (zoomLevel < 0.1) zoomLevel = 0.1 + if (zoomLevel > 5.0) zoomLevel = 5.0 + } + } + // Grid Shader /* ShaderEffect { @@ -224,37 +255,6 @@ Item { } } } - } - - // Input Handler for Pan/Zoom - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.MiddleButton | Qt.LeftButton - property point lastPos: Qt.point(0, 0) - - onPressed: (mouse) => { - lastPos = Qt.point(mouse.x, mouse.y) - } - - onPositionChanged: (mouse) => { - if (pressedButtons & Qt.MiddleButton || (pressedButtons & Qt.LeftButton && (mouse.modifiers & Qt.AltModifier))) { - var delta = Qt.point(mouse.x - lastPos.x, mouse.y - lastPos.y) - root.panOffset = Qt.point(root.panOffset.x + delta.x, root.panOffset.y + delta.y) - lastPos = Qt.point(mouse.x, mouse.y) - } - } - - onWheel: (wheel) => { - var zoomFactor = 1.1 - if (wheel.angleDelta.y < 0) { - zoomLevel /= zoomFactor - } else { - zoomLevel *= zoomFactor - } - // Clamp zoom - if (zoomLevel < 0.1) zoomLevel = 0.1 - if (zoomLevel > 5.0) zoomLevel = 5.0 - } - } } -} + } + } From a229d5fd34e8acf322590c208bc24a0612a780e9 Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Sun, 23 Nov 2025 22:32:45 -0300 Subject: [PATCH 11/33] moving lines --- resources/qml/Connection.qml | 36 ++++++++++++++++++++++++++++++++++-- resources/qml/NodeGraph.qml | 4 ++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/resources/qml/Connection.qml b/resources/qml/Connection.qml index bf03d49c8..edc5529f2 100644 --- a/resources/qml/Connection.qml +++ b/resources/qml/Connection.qml @@ -22,10 +22,42 @@ Shape { } } + // Monitor changes in node position + Connections { + target: sourceNode + function onXChanged() { root.updateStartPos() } + function onYChanged() { root.updateStartPos() } + } + Connections { + target: destNode + function onXChanged() { root.updateEndPos() } + function onYChanged() { root.updateEndPos() } + } + // 0 = In, 1 = Out. // Source is Out (1), Dest is In (0). - property point startPos: (sourceNode && sourceNode.completed) ? sourceNode.getPortPos(1, sourcePortIndex) : Qt.point(0,0) - property point endPos: (destNode && destNode.completed) ? destNode.getPortPos(0, destPortIndex) : Qt.point(0,0) + property point startPos: Qt.point(0,0) + property point endPos: Qt.point(0,0) + + function updateStartPos() { + if (sourceNode && sourceNode.completed) { + startPos = sourceNode.getPortPos(1, sourcePortIndex) + } + } + + function updateEndPos() { + if (destNode && destNode.completed) { + endPos = destNode.getPortPos(0, destPortIndex) + } + } + + onSourceNodeChanged: updateStartPos() + onDestNodeChanged: updateEndPos() + + Component.onCompleted: { + updateStartPos() + updateEndPos() + } visible: sourceNode !== undefined && destNode !== undefined && sourceNode.completed && destNode.completed diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index b189ef253..d62bb9215 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -223,10 +223,10 @@ Item { contentDelegate: root.nodeContentDelegate onXChanged: { - if (completed) graphModel.nodes.moveNode(nodeId, x, y) + if (completed && Math.abs(x - initialX) > 0.1) graphModel.nodes.moveNode(nodeId, x, y) } onYChanged: { - if (completed) graphModel.nodes.moveNode(nodeId, x, y) + if (completed && Math.abs(y - initialY) > 0.1) graphModel.nodes.moveNode(nodeId, x, y) } Component.onCompleted: { From f6b1c075ba556aebb36c54fae0f90933afc516eb Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Sun, 23 Nov 2025 22:36:08 -0300 Subject: [PATCH 12/33] readme qml --- README_QML.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 README_QML.md diff --git a/README_QML.md b/README_QML.md new file mode 100644 index 000000000..ee01f145b --- /dev/null +++ b/README_QML.md @@ -0,0 +1,73 @@ +# QML Support for QtNodes + +This document describes the implementation of QML support for the **QtNodes** library. This feature allows developers to build modern, hardware-accelerated node editor interfaces using Qt Quick/QML while leveraging the robust C++ graph logic of QtNodes. + +## Architecture + +The implementation follows a Model-View-ViewModel (MVVM) pattern adapted for Qt/QML: + +### 1. C++ Integration Layer (`src/qml/`) +* **`QuickGraphModel`**: The main controller class. It wraps the internal `DataFlowGraphModel` and exposes high-level operations (add/remove nodes, create connections) to QML. +* **`NodesListModel`**: A `QAbstractListModel` that exposes the nodes in the graph. It provides roles for properties like position, caption, and input/output port counts. Crucially, it exposes the underlying `NodeDelegateModel` as a `QObject*`, allowing QML to bind directly to custom node data (e.g., numbers, text). +* **`ConnectionsListModel`**: A `QAbstractListModel` that tracks active connections, providing source/destination node IDs and port indices. + +### 2. QML Components (`resources/qml/`) +* **`NodeGraph.qml`**: The main canvas component. + * Handles **Infinite Panning & Zooming**. + * Renders a dynamic **Grid** using standard Canvas drawing. + * Manages the lifecycle of Nodes and Connections using `Repeater`s. + * Handles **Connection Drafting**: Drawing a temporary line while the user drags from a port. +* **`Node.qml`**: A generic node shell. + * Displays the node caption and background. + * Generates input/output ports dynamically. + * Uses a `Loader` with a `nodeContentDelegate` to allow users to inject **custom QML content** inside the node (e.g., text fields, images). + * Handles node dragging and position updates. +* **`Connection.qml`**: + * Renders connections as smooth cubic Bezier curves using `QtQuick.Shapes`. + * Updates geometry in real-time when linked nodes are moved. + +## Features Implemented + +* ✅ **Hybrid C++/QML Architecture**: Full separation of graph logic (C++) and UI (QML). +* ✅ **Dynamic Graph Rendering**: Nodes and connections appear and update automatically based on the C++ model. +* ✅ **Interactive Workspace**: Smooth zooming and panning of the graph canvas. +* ✅ **Node Manipulation**: Drag-and-drop nodes to move them. +* ✅ **Connection Creation**: Drag from any port to a compatible target port to create a connection. +* ✅ **Customizable Nodes**: Users can define the look and behavior of specific node types (e.g., "NumberSource") completely in QML. +* ✅ **Example Application**: `qml_calculator` demonstrates a working calculator where C++ handles the math and QML handles the UI. + +## How to Build + +1. Ensure you have Qt 5.15+ or Qt 6 installed with the **Qt Quick** and **Qt Quick Controls 2** modules. +2. Run CMake with the `BUILD_QML` flag: + +```bash +mkdir build && cd build +cmake .. -DBUILD_QML=ON +make +``` + +3. Run the example: +```bash +./bin/qml_calculator +``` + +## Next Steps (Roadmap) + +To achieve full feature parity with the Widgets-based version, the following features need to be implemented: + +1. **Connection Interaction**: + * Ability to select/highlight existing connections. + * Ability to delete connections (e.g., via right-click menu or keyboard shortcut). +2. **Node Deletion**: + * UI mechanism to delete selected nodes. +3. **Selection Model**: + * Support for selecting multiple nodes (marquee selection). + * Visual feedback for selected states. +4. **Undo/Redo Stack**: + * Expose the C++ `UndoStack` to QML to trigger undo/redo actions. +5. **Styling**: + * Expose more style properties (colors, line thickness) to QML for easy theming. +6. **Port Data & Type Safety**: + * Visualize port data types (colors based on type). + * Add visual feedback during connection dragging (highlight compatible ports, dim incompatible ones). From d7fe81b86890cdc21d8510901365aac85aa1d9e7 Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Sun, 23 Nov 2025 22:52:50 -0300 Subject: [PATCH 13/33] fix temp line position --- resources/qml/Node.qml | 8 ++++---- resources/qml/NodeGraph.qml | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/resources/qml/Node.qml b/resources/qml/Node.qml index 345619883..e48a8bc28 100644 --- a/resources/qml/Node.qml +++ b/resources/qml/Node.qml @@ -102,11 +102,11 @@ Rectangle { graph.setActivePort(null) } onPressed: (mouse) => { - var pos = root.mapToItem(graph.canvas, x + parent.x + inPortsColumn.x + width/2, y + parent.y + inPortsColumn.y + height/2) + var pos = mapToItem(graph.canvas, width/2, height/2) graph.startDraftConnection(root.nodeId, 0, index, pos) } onPositionChanged: (mouse) => { - var pos = root.mapToItem(graph.canvas, mouse.x + x + parent.x + inPortsColumn.x, mouse.y + y + parent.y + inPortsColumn.y) + var pos = mapToItem(graph.canvas, mouse.x, mouse.y) graph.updateDraftConnection(pos) } onReleased: { @@ -148,11 +148,11 @@ Rectangle { } onPressed: (mouse) => { - var pos = root.mapToItem(graph.canvas, x + parent.x + outPortsColumn.x + width/2, y + parent.y + outPortsColumn.y + height/2) + var pos = mapToItem(graph.canvas, width/2, height/2) graph.startDraftConnection(root.nodeId, 1, index, pos) } onPositionChanged: (mouse) => { - var pos = root.mapToItem(graph.canvas, mouse.x + x + parent.x + outPortsColumn.x, mouse.y + y + parent.y + outPortsColumn.y) + var pos = mapToItem(graph.canvas, mouse.x, mouse.y) graph.updateDraftConnection(pos) } onReleased: { diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index d62bb9215..a1c5b84cb 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -6,6 +6,7 @@ import QtNodes 1.0 Item { id: root property QuickGraphModel graphModel + property alias canvas: canvas property var nodeItems: ({}) property Component nodeContentDelegate // User provided content From 5623e87e619ca8647d87a16ad00745e527b5fcec Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Sun, 23 Nov 2025 23:05:00 -0300 Subject: [PATCH 14/33] fix drop connection --- resources/qml/Node.qml | 66 ++++++++++++++++++++++++++++++++----- resources/qml/NodeGraph.qml | 25 ++++++++++++++ 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/resources/qml/Node.qml b/resources/qml/Node.qml index e48a8bc28..33d183744 100644 --- a/resources/qml/Node.qml +++ b/resources/qml/Node.qml @@ -94,13 +94,25 @@ Rectangle { anchors.fill: parent hoverEnabled: true onEntered: { - parent.scale = 1.2 - graph.setActivePort({nodeId: root.nodeId, portType: 0, portIndex: index}) + // Only highlight if not dragging or if we are the target + if (!graph.isDragging) { + parent.scale = 1.2 + graph.setActivePort({nodeId: root.nodeId, portType: 0, portIndex: index}) + } } onExited: { - parent.scale = 1.0 - graph.setActivePort(null) + if (!graph.isDragging) { + parent.scale = 1.0 + graph.setActivePort(null) + } } + + // Visual feedback based on activePort + property bool isActive: { + var ap = graph.activePort + return ap && ap.nodeId === root.nodeId && ap.portType === 0 && ap.portIndex === index + } + onIsActiveChanged: parent.scale = isActive ? 1.4 : 1.0 onPressed: (mouse) => { var pos = mapToItem(graph.canvas, width/2, height/2) graph.startDraftConnection(root.nodeId, 0, index, pos) @@ -139,13 +151,23 @@ Rectangle { anchors.fill: parent hoverEnabled: true onEntered: { - parent.scale = 1.2 - graph.setActivePort({nodeId: root.nodeId, portType: 1, portIndex: index}) + if (!graph.isDragging) { + parent.scale = 1.2 + graph.setActivePort({nodeId: root.nodeId, portType: 1, portIndex: index}) + } } onExited: { - parent.scale = 1.0 - graph.setActivePort(null) + if (!graph.isDragging) { + parent.scale = 1.0 + graph.setActivePort(null) + } + } + + property bool isActive: { + var ap = graph.activePort + return ap && ap.nodeId === root.nodeId && ap.portType === 1 && ap.portIndex === index } + onIsActiveChanged: parent.scale = isActive ? 1.4 : 1.0 onPressed: (mouse) => { var pos = mapToItem(graph.canvas, width/2, height/2) @@ -163,6 +185,34 @@ Rectangle { } } + function getPortInfoAt(x, y) { + // Map node-local coordinates to find which port is under mouse + // Check input ports + var inPos = inPortsColumn.mapFromItem(root, x, y) + var inChild = inPortsColumn.childAt(inPos.x, inPos.y) + + if (inChild) { + // Find index of this child in the repeater + for (var i = 0; i < inRepeater.count; ++i) { + if (inRepeater.itemAt(i) === inChild) { + return {nodeId: root.nodeId, portType: 0, portIndex: i} + } + } + } + + var outPos = outPortsColumn.mapFromItem(root, x, y) + var outChild = outPortsColumn.childAt(outPos.x, outPos.y) + + if (outChild) { + for (var j = 0; j < outRepeater.count; ++j) { + if (outRepeater.itemAt(j) === outChild) { + return {nodeId: root.nodeId, portType: 1, portIndex: j} + } + } + } + return null + } + function getPortPos(type, index) { var repeater = (type === 0) ? inRepeater : outRepeater var portItem = repeater.itemAt(index) diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index a1c5b84cb..c3e6097eb 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -41,6 +41,31 @@ Item { function updateDraftConnection(pos) { dragCurrent = pos + + // Hit testing for potential target port + // Use geometry-based search instead of childAt to avoid z-ordering issues with the drag line itself + var targetNode = null + + for (var id in nodeItems) { + var node = nodeItems[id] + // nodeItems is a map, check if node is valid + if (node && node.visible) { + // Map canvas pos to node local + var localPos = node.mapFromItem(canvas, pos.x, pos.y) + if (node.contains(Qt.point(localPos.x, localPos.y))) { + targetNode = node + break + } + } + } + + if (targetNode && typeof targetNode.getPortInfoAt === 'function') { + var nodeLocalPos = canvas.mapToItem(targetNode, pos.x, pos.y) + var portInfo = targetNode.getPortInfoAt(nodeLocalPos.x, nodeLocalPos.y) + setActivePort(portInfo) + } else { + setActivePort(null) + } } function endDraftConnection() { From 4c05128812d66262bd02411652db6864cb2e8bad Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Sun, 23 Nov 2025 23:09:21 -0300 Subject: [PATCH 15/33] update readme --- README_QML.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/README_QML.md b/README_QML.md index ee01f145b..920035860 100644 --- a/README_QML.md +++ b/README_QML.md @@ -13,18 +13,18 @@ The implementation follows a Model-View-ViewModel (MVVM) pattern adapted for Qt/ ### 2. QML Components (`resources/qml/`) * **`NodeGraph.qml`**: The main canvas component. - * Handles **Infinite Panning & Zooming**. - * Renders a dynamic **Grid** using standard Canvas drawing. - * Manages the lifecycle of Nodes and Connections using `Repeater`s. - * Handles **Connection Drafting**: Drawing a temporary line while the user drags from a port. + * Handles **Infinite Panning & Zooming** using a background `MouseArea` and transform/scale logic. + * Renders a dynamic **Infinite Grid** using a `Canvas` item (avoiding shader compatibility issues). + * Manages the lifecycle of Nodes and Connections using `Repeater`s linked to the C++ models. + * Handles **Connection Drafting**: Implements geometry-based hit-testing to reliably find target nodes/ports under the mouse cursor, ignoring z-order overlays. * **`Node.qml`**: A generic node shell. * Displays the node caption and background. * Generates input/output ports dynamically. - * Uses a `Loader` with a `nodeContentDelegate` to allow users to inject **custom QML content** inside the node (e.g., text fields, images). - * Handles node dragging and position updates. + * Uses a `Loader` with a `nodeContentDelegate` to allow users to inject **custom QML content** inside the node (e.g., text fields, images) with full property binding propagation. + * Handles node dragging and position updates, with feedback loops prevented by threshold checks. * **`Connection.qml`**: * Renders connections as smooth cubic Bezier curves using `QtQuick.Shapes`. - * Updates geometry in real-time when linked nodes are moved. + * Updates geometry in real-time when linked nodes are moved by monitoring specific `xChanged`/`yChanged` signals. ## Features Implemented @@ -36,6 +36,11 @@ The implementation follows a Model-View-ViewModel (MVVM) pattern adapted for Qt/ * ✅ **Customizable Nodes**: Users can define the look and behavior of specific node types (e.g., "NumberSource") completely in QML. * ✅ **Example Application**: `qml_calculator` demonstrates a working calculator where C++ handles the math and QML handles the UI. +## Technical Notes +* **Grid Implementation**: The grid is drawn using an HTML5-style `Canvas` API rather than GLSL shaders. This ensures compatibility with Qt 6's RHI (which removed inline OpenGL shaders) while maintaining performance for infinite grid rendering. +* **Z-Ordering & Hit Testing**: Custom geometry-based hit testing is used for connection drafting because the temporary connection line (a `Shape` item) overlays the nodes, blocking standard `childAt` calls. +* **Coordinate Mapping**: All drag operations use `mapToItem`/`mapFromItem` relative to the main `canvas` item to ensure correct positioning regardless of the current pan/zoom state. + ## How to Build 1. Ensure you have Qt 5.15+ or Qt 6 installed with the **Qt Quick** and **Qt Quick Controls 2** modules. From 0a987df20373cec160781219e139a13396c2147a Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Tue, 2 Dec 2025 21:47:02 -0300 Subject: [PATCH 16/33] feat: zoom with pan --- resources/qml/NodeGraph.qml | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index c3e6097eb..a28e47296 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -117,14 +117,27 @@ Item { onWheel: (wheel) => { var zoomFactor = 1.1 - if (wheel.angleDelta.y < 0) { - zoomLevel /= zoomFactor - } else { - zoomLevel *= zoomFactor - } - // Clamp zoom - if (zoomLevel < 0.1) zoomLevel = 0.1 - if (zoomLevel > 5.0) zoomLevel = 5.0 + var oldZoom = zoomLevel + var newZoom = (wheel.angleDelta.y < 0) ? oldZoom / zoomFactor : oldZoom * zoomFactor + + // Clamp + newZoom = Math.max(0.1, Math.min(5.0, newZoom)) + + // Mouse position on screen + var mouseX = wheel.x + var mouseY = wheel.y + + // Position in canvas (world coordinates) + var canvasX = (mouseX - panOffset.x) / oldZoom + var canvasY = (mouseY - panOffset.y) / oldZoom + + // Adjust pan to keep point fixed under mouse + panOffset = Qt.point( + mouseX - canvasX * newZoom, + mouseY - canvasY * newZoom + ) + + zoomLevel = newZoom } } From b39bb6ca167188a8476c3ce6d89f714069eab8e4 Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Tue, 2 Dec 2025 21:56:36 -0300 Subject: [PATCH 17/33] fix grip port z index --- resources/qml/Node.qml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/resources/qml/Node.qml b/resources/qml/Node.qml index 33d183744..560367c6d 100644 --- a/resources/qml/Node.qml +++ b/resources/qml/Node.qml @@ -75,6 +75,7 @@ Rectangle { // Input Ports Column { id: inPortsColumn + z: 10 anchors.left: parent.left anchors.top: parent.top anchors.topMargin: 35 @@ -93,6 +94,7 @@ Rectangle { MouseArea { anchors.fill: parent hoverEnabled: true + preventStealing: true onEntered: { // Only highlight if not dragging or if we are the target if (!graph.isDragging) { @@ -132,6 +134,7 @@ Rectangle { // Output Ports Column { id: outPortsColumn + z: 10 anchors.right: parent.right anchors.top: parent.top anchors.topMargin: 35 @@ -150,6 +153,7 @@ Rectangle { MouseArea { anchors.fill: parent hoverEnabled: true + preventStealing: true onEntered: { if (!graph.isDragging) { parent.scale = 1.2 From 30d48532c31d0a5880167f2b89fff641d90983bc Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Tue, 2 Dec 2025 22:08:13 -0300 Subject: [PATCH 18/33] fix desconection --- include/QtNodes/qml/QuickGraphModel.hpp | 1 + resources/qml/Node.qml | 15 +++++++++++++-- src/qml/QuickGraphModel.cpp | 21 +++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/include/QtNodes/qml/QuickGraphModel.hpp b/include/QtNodes/qml/QuickGraphModel.hpp index c939c968a..22f791def 100644 --- a/include/QtNodes/qml/QuickGraphModel.hpp +++ b/include/QtNodes/qml/QuickGraphModel.hpp @@ -35,6 +35,7 @@ class QuickGraphModel : public QObject Q_INVOKABLE void addConnection(int outNodeId, int outPortIndex, int inNodeId, int inPortIndex); Q_INVOKABLE void removeConnection(int outNodeId, int outPortIndex, int inNodeId, int inPortIndex); + Q_INVOKABLE QVariantMap getConnectionAtInput(int nodeId, int portIndex); private: std::shared_ptr _model; diff --git a/resources/qml/Node.qml b/resources/qml/Node.qml index 560367c6d..a8db63dce 100644 --- a/resources/qml/Node.qml +++ b/resources/qml/Node.qml @@ -116,8 +116,19 @@ Rectangle { } onIsActiveChanged: parent.scale = isActive ? 1.4 : 1.0 onPressed: (mouse) => { - var pos = mapToItem(graph.canvas, width/2, height/2) - graph.startDraftConnection(root.nodeId, 0, index, pos) + var existing = graph.graphModel.getConnectionAtInput(root.nodeId, index) + + if (existing.valid) { + // Remove existing connection and start draft from source + graph.graphModel.removeConnection(existing.outNodeId, existing.outPortIndex, + root.nodeId, index) + var sourceNode = graph.nodeItems[existing.outNodeId] + var sourcePos = sourceNode.getPortPos(1, existing.outPortIndex) + graph.startDraftConnection(existing.outNodeId, 1, existing.outPortIndex, sourcePos) + } else { + var pos = mapToItem(graph.canvas, width/2, height/2) + graph.startDraftConnection(root.nodeId, 0, index, pos) + } } onPositionChanged: (mouse) => { var pos = mapToItem(graph.canvas, mouse.x, mouse.y) diff --git a/src/qml/QuickGraphModel.cpp b/src/qml/QuickGraphModel.cpp index 34ea815fa..ba8b21d62 100644 --- a/src/qml/QuickGraphModel.cpp +++ b/src/qml/QuickGraphModel.cpp @@ -88,4 +88,25 @@ void QuickGraphModel::removeConnection(int outNodeId, int outPortIndex, int inNo _model->deleteConnection(connId); } +QVariantMap QuickGraphModel::getConnectionAtInput(int nodeId, int portIndex) +{ + QVariantMap result; + result["valid"] = false; + + if (!_model) return result; + + auto connections = _model->allConnectionIds(static_cast(nodeId)); + for (const auto& conn : connections) { + if (conn.inNodeId == static_cast(nodeId) && + conn.inPortIndex == static_cast(portIndex)) { + result["valid"] = true; + result["outNodeId"] = static_cast(conn.outNodeId); + result["outPortIndex"] = static_cast(conn.outPortIndex); + return result; + } + } + + return result; +} + } // namespace QtNodes From 9a9c0e23f7024faa43cfc60aa4f28a3f2503f484 Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Tue, 2 Dec 2025 22:10:22 -0300 Subject: [PATCH 19/33] fix desconect render --- resources/qml/Node.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/qml/Node.qml b/resources/qml/Node.qml index a8db63dce..0ec944bf6 100644 --- a/resources/qml/Node.qml +++ b/resources/qml/Node.qml @@ -117,6 +117,7 @@ Rectangle { onIsActiveChanged: parent.scale = isActive ? 1.4 : 1.0 onPressed: (mouse) => { var existing = graph.graphModel.getConnectionAtInput(root.nodeId, index) + var mousePos = mapToItem(graph.canvas, mouse.x, mouse.y) if (existing.valid) { // Remove existing connection and start draft from source @@ -125,6 +126,7 @@ Rectangle { var sourceNode = graph.nodeItems[existing.outNodeId] var sourcePos = sourceNode.getPortPos(1, existing.outPortIndex) graph.startDraftConnection(existing.outNodeId, 1, existing.outPortIndex, sourcePos) + graph.updateDraftConnection(mousePos) } else { var pos = mapToItem(graph.canvas, width/2, height/2) graph.startDraftConnection(root.nodeId, 0, index, pos) From 7d9f2149f7917ddb9ee5e6d4eb51e95674268fa9 Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Tue, 2 Dec 2025 22:14:14 -0300 Subject: [PATCH 20/33] fix focus --- resources/qml/Node.qml | 7 +++++++ resources/qml/NodeGraph.qml | 1 + 2 files changed, 8 insertions(+) diff --git a/resources/qml/Node.qml b/resources/qml/Node.qml index 0ec944bf6..e615e1954 100644 --- a/resources/qml/Node.qml +++ b/resources/qml/Node.qml @@ -31,8 +31,15 @@ Rectangle { completed = true } + TapHandler { + onTapped: graph.forceActiveFocus() + } + DragHandler { target: root + onActiveChanged: { + if (active) graph.forceActiveFocus() + } } Text { diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index a28e47296..3574caa2b 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -104,6 +104,7 @@ Item { property point lastPos: Qt.point(0, 0) onPressed: (mouse) => { + root.forceActiveFocus() lastPos = Qt.point(mouse.x, mouse.y) } From c954d11097e8386cfe577e277797975377360f7c Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Tue, 2 Dec 2025 22:19:57 -0300 Subject: [PATCH 21/33] ex select all --- examples/qml_calculator/main.qml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/qml_calculator/main.qml b/examples/qml_calculator/main.qml index f69f4c206..98ff7d750 100644 --- a/examples/qml_calculator/main.qml +++ b/examples/qml_calculator/main.qml @@ -63,6 +63,9 @@ Window { onEditingFinished: { if (delegateModel) delegateModel.number = parseFloat(text) } + onActiveFocusChanged: { + if (activeFocus) selectAll() + } color: "black" background: Rectangle { color: "white" } } From e9d22c5ad5076eefbcc1217e2227d8a2384bc476 Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Tue, 2 Dec 2025 22:21:14 -0300 Subject: [PATCH 22/33] fix node z on dragging --- resources/qml/Node.qml | 5 ++++- resources/qml/NodeGraph.qml | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/resources/qml/Node.qml b/resources/qml/Node.qml index e615e1954..50bc29197 100644 --- a/resources/qml/Node.qml +++ b/resources/qml/Node.qml @@ -38,7 +38,10 @@ Rectangle { DragHandler { target: root onActiveChanged: { - if (active) graph.forceActiveFocus() + if (active) { + graph.forceActiveFocus() + graph.bringToFront(root) + } } } diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index 3574caa2b..46ce8b1eb 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -25,6 +25,13 @@ Item { // Port dragging property var activePort: null + // Z-order management + property int topZ: 1 + function bringToFront(nodeItem) { + topZ++ + nodeItem.z = topZ + } + // Temporary drafting connection property point dragStart: Qt.point(0, 0) property point dragCurrent: Qt.point(0, 0) From f01b52d733564d58f807c414c301f1eda0e64014 Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Tue, 2 Dec 2025 22:28:27 -0300 Subject: [PATCH 23/33] feat: nodes selection --- resources/qml/Node.qml | 53 ++++++++++++++- resources/qml/NodeGraph.qml | 127 +++++++++++++++++++++++++++++++++++- 2 files changed, 176 insertions(+), 4 deletions(-) diff --git a/resources/qml/Node.qml b/resources/qml/Node.qml index 50bc29197..68f97b55e 100644 --- a/resources/qml/Node.qml +++ b/resources/qml/Node.qml @@ -15,6 +15,10 @@ Rectangle { property real initialY property bool completed: false + property bool selected: { + graph.selectionVersion + return graph.isNodeSelected(nodeId) + } x: initialX y: initialY @@ -23,8 +27,8 @@ Rectangle { height: Math.max(Math.max(inPorts, outPorts) * 20 + 40, 50) color: "#2d2d2d" - border.color: "black" - border.width: 2 + border.color: selected ? "#4a9eff" : "black" + border.width: selected ? 3 : 2 radius: 5 Component.onCompleted: { @@ -32,15 +36,58 @@ Rectangle { } TapHandler { - onTapped: graph.forceActiveFocus() + onTapped: (eventPoint, button) => { + graph.forceActiveFocus() + var additive = (eventPoint.event.modifiers & Qt.ControlModifier) + if (additive) { + graph.toggleNodeSelection(nodeId) + } else { + graph.selectNode(nodeId, false) + } + } } DragHandler { + id: dragHandler target: root + + property point lastPos: Qt.point(0, 0) + property bool isDraggingGroup: false + onActiveChanged: { if (active) { graph.forceActiveFocus() graph.bringToFront(root) + lastPos = Qt.point(root.x, root.y) + + // If this node is selected and there are multiple selections, enable group drag + isDraggingGroup = root.selected && Object.keys(graph.selectedNodeIds).length > 1 + + // If not selected, select only this node + if (!root.selected) { + graph.selectNode(nodeId, false) + } + } + } + + onTranslationChanged: { + if (isDraggingGroup) { + var deltaX = root.x - lastPos.x + var deltaY = root.y - lastPos.y + + // Move all other selected nodes by the same delta + var selectedIds = graph.getSelectedNodeIds() + for (var i = 0; i < selectedIds.length; i++) { + var id = selectedIds[i] + if (id !== nodeId) { + var node = graph.nodeItems[id] + if (node) { + node.x += deltaX + node.y += deltaY + } + } + } + lastPos = Qt.point(root.x, root.y) } } } diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index 46ce8b1eb..fa04dbf34 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -32,6 +32,81 @@ Item { nodeItem.z = topZ } + // Selection management + property var selectedNodeIds: ({}) + property int selectionVersion: 0 + + signal selectionChanged() + + function isNodeSelected(nodeId) { + return selectedNodeIds.hasOwnProperty(nodeId) + } + + function selectNode(nodeId, additive) { + if (!additive) { + selectedNodeIds = {} + } + if (!selectedNodeIds.hasOwnProperty(nodeId)) { + selectedNodeIds[nodeId] = true + selectionVersion++ + selectionChanged() + } + } + + function deselectNode(nodeId) { + if (selectedNodeIds.hasOwnProperty(nodeId)) { + delete selectedNodeIds[nodeId] + selectionVersion++ + selectionChanged() + } + } + + function toggleNodeSelection(nodeId) { + if (selectedNodeIds.hasOwnProperty(nodeId)) { + delete selectedNodeIds[nodeId] + } else { + selectedNodeIds[nodeId] = true + } + selectionVersion++ + selectionChanged() + } + + function clearSelection() { + selectedNodeIds = {} + selectionVersion++ + selectionChanged() + } + + function selectNodesInRect(rect) { + for (var id in nodeItems) { + var node = nodeItems[id] + if (node) { + var nodeRect = Qt.rect(node.x, node.y, node.width, node.height) + if (rectsIntersect(rect, nodeRect)) { + selectedNodeIds[id] = true + } + } + } + selectionVersion++ + selectionChanged() + } + + function rectsIntersect(r1, r2) { + return !(r2.x > r1.x + r1.width || + r2.x + r2.width < r1.x || + r2.y > r1.y + r1.height || + r2.y + r2.height < r1.y) + } + + function getSelectedNodeIds() { + return Object.keys(selectedNodeIds).map(function(id) { return parseInt(id) }) + } + + // Marquee selection + property bool isMarqueeSelecting: false + property point marqueeStart: Qt.point(0, 0) + property point marqueeEnd: Qt.point(0, 0) + // Temporary drafting connection property point dragStart: Qt.point(0, 0) property point dragCurrent: Qt.point(0, 0) @@ -104,7 +179,7 @@ Item { color: "#2b2b2b" clip: true - // Input Handler for Pan/Zoom + // Input Handler for Pan/Zoom/Selection MouseArea { anchors.fill: parent acceptedButtons: Qt.MiddleButton | Qt.LeftButton @@ -113,6 +188,23 @@ Item { onPressed: (mouse) => { root.forceActiveFocus() lastPos = Qt.point(mouse.x, mouse.y) + + // Left click without Alt starts marquee selection + if (mouse.button === Qt.LeftButton && !(mouse.modifiers & Qt.AltModifier)) { + // Convert screen position to canvas coordinates + var canvasPos = Qt.point( + (mouse.x - root.panOffset.x) / root.zoomLevel, + (mouse.y - root.panOffset.y) / root.zoomLevel + ) + root.marqueeStart = canvasPos + root.marqueeEnd = canvasPos + root.isMarqueeSelecting = true + + // Clear selection unless Ctrl is held + if (!(mouse.modifiers & Qt.ControlModifier)) { + root.clearSelection() + } + } } onPositionChanged: (mouse) => { @@ -120,6 +212,27 @@ Item { var delta = Qt.point(mouse.x - lastPos.x, mouse.y - lastPos.y) root.panOffset = Qt.point(root.panOffset.x + delta.x, root.panOffset.y + delta.y) lastPos = Qt.point(mouse.x, mouse.y) + } else if (root.isMarqueeSelecting) { + var canvasPos = Qt.point( + (mouse.x - root.panOffset.x) / root.zoomLevel, + (mouse.y - root.panOffset.y) / root.zoomLevel + ) + root.marqueeEnd = canvasPos + } + } + + onReleased: (mouse) => { + if (root.isMarqueeSelecting) { + // Select nodes in marquee rect + var x = Math.min(root.marqueeStart.x, root.marqueeEnd.x) + var y = Math.min(root.marqueeStart.y, root.marqueeEnd.y) + var w = Math.abs(root.marqueeEnd.x - root.marqueeStart.x) + var h = Math.abs(root.marqueeEnd.y - root.marqueeStart.y) + + if (w > 5 || h > 5) { + root.selectNodesInRect(Qt.rect(x, y, w, h)) + } + root.isMarqueeSelecting = false } } @@ -302,6 +415,18 @@ Item { } } } + + // Marquee Selection Rectangle + Rectangle { + visible: root.isMarqueeSelecting + x: Math.min(root.marqueeStart.x, root.marqueeEnd.x) + y: Math.min(root.marqueeStart.y, root.marqueeEnd.y) + width: Math.abs(root.marqueeEnd.x - root.marqueeStart.x) + height: Math.abs(root.marqueeEnd.y - root.marqueeStart.y) + color: "#224a9eff" + border.color: "#4a9eff" + border.width: 1 + } } } } From f3e0a101dc96b21709f96a0621aa924aae633182 Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Tue, 2 Dec 2025 22:32:14 -0300 Subject: [PATCH 24/33] fix click selection --- resources/qml/Node.qml | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/resources/qml/Node.qml b/resources/qml/Node.qml index 68f97b55e..f27e3c6c4 100644 --- a/resources/qml/Node.qml +++ b/resources/qml/Node.qml @@ -47,6 +47,23 @@ Rectangle { } } + // Separate handler for pointer press to handle selection on mouse down + PointHandler { + id: pointHandler + acceptedButtons: Qt.LeftButton + onActiveChanged: { + if (active) { + graph.forceActiveFocus() + var additive = (point.modifiers & Qt.ControlModifier) + if (additive) { + graph.toggleNodeSelection(nodeId) + } else if (!root.selected) { + graph.selectNode(nodeId, false) + } + } + } + } + DragHandler { id: dragHandler target: root @@ -56,17 +73,11 @@ Rectangle { onActiveChanged: { if (active) { - graph.forceActiveFocus() graph.bringToFront(root) lastPos = Qt.point(root.x, root.y) // If this node is selected and there are multiple selections, enable group drag isDraggingGroup = root.selected && Object.keys(graph.selectedNodeIds).length > 1 - - // If not selected, select only this node - if (!root.selected) { - graph.selectNode(nodeId, false) - } } } From 215cc4c09e6609573f9f00574254d313e982df98 Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Tue, 2 Dec 2025 22:39:04 -0300 Subject: [PATCH 25/33] feat: node deletion --- resources/qml/Connection.qml | 167 ++++++++++++++++++++++++++++------- resources/qml/NodeGraph.qml | 125 +++++++++++++++++++++++++- 2 files changed, 256 insertions(+), 36 deletions(-) diff --git a/resources/qml/Connection.qml b/resources/qml/Connection.qml index edc5529f2..06333b1d9 100644 --- a/resources/qml/Connection.qml +++ b/resources/qml/Connection.qml @@ -1,11 +1,10 @@ import QtQuick 2.15 import QtQuick.Shapes 1.15 -Shape { +Item { id: root property var graph - // Roles from ConnectionsListModel are expected to be set on this item by the Repeater property int sourceNodeId: -1 property int sourcePortIndex: -1 property int destNodeId: -1 @@ -14,68 +13,168 @@ Shape { property var sourceNode: graph.nodeItems[sourceNodeId] property var destNode: graph.nodeItems[destNodeId] + property bool selected: graph.isConnectionSelected(sourceNodeId, sourcePortIndex, destNodeId, destPortIndex) + property bool hovered: false + Connections { target: graph function onNodeRegistryChanged() { - sourceNode = graph.nodeItems[sourceNodeId] - destNode = graph.nodeItems[destNodeId] + sourceNode = graph.nodeItems[sourceNodeId] + destNode = graph.nodeItems[destNodeId] + } + function onConnectionSelectionChanged() { + selected = graph.isConnectionSelected(sourceNodeId, sourcePortIndex, destNodeId, destPortIndex) } } - // Monitor changes in node position Connections { target: sourceNode - function onXChanged() { root.updateStartPos() } - function onYChanged() { root.updateStartPos() } + function onXChanged() { root.updatePositions() } + function onYChanged() { root.updatePositions() } } Connections { target: destNode - function onXChanged() { root.updateEndPos() } - function onYChanged() { root.updateEndPos() } + function onXChanged() { root.updatePositions() } + function onYChanged() { root.updatePositions() } } - // 0 = In, 1 = Out. - // Source is Out (1), Dest is In (0). property point startPos: Qt.point(0,0) property point endPos: Qt.point(0,0) - function updateStartPos() { + function updatePositions() { if (sourceNode && sourceNode.completed) { startPos = sourceNode.getPortPos(1, sourcePortIndex) } - } - - function updateEndPos() { if (destNode && destNode.completed) { endPos = destNode.getPortPos(0, destPortIndex) } } - onSourceNodeChanged: updateStartPos() - onDestNodeChanged: updateEndPos() + onSourceNodeChanged: updatePositions() + onDestNodeChanged: updatePositions() - Component.onCompleted: { - updateStartPos() - updateEndPos() - } + Component.onCompleted: updatePositions() visible: sourceNode !== undefined && destNode !== undefined && sourceNode.completed && destNode.completed - ShapePath { - strokeWidth: 3 - strokeColor: "#eeeeee" - fillColor: "transparent" + // Bounding box for hit detection + property real minX: Math.min(startPos.x, endPos.x) - 20 + property real minY: Math.min(startPos.y, endPos.y) - 20 + property real maxX: Math.max(startPos.x, endPos.x) + 20 + property real maxY: Math.max(startPos.y, endPos.y) + 20 + + // Hit detection MouseArea covering the bounding box + MouseArea { + x: root.minX + y: root.minY + width: root.maxX - root.minX + height: root.maxY - root.minY + hoverEnabled: true + acceptedButtons: Qt.LeftButton + propagateComposedEvents: true + + property bool isOverCurve: false - startX: root.startPos.x - startY: root.startPos.y + onPositionChanged: (mouse) => { + var canvasX = mouse.x + root.minX + var canvasY = mouse.y + root.minY + isOverCurve = root.distanceToCurve(canvasX, canvasY) < 10 + root.hovered = isOverCurve + } + + onExited: { + isOverCurve = false + root.hovered = false + } + + onPressed: (mouse) => { + var canvasX = mouse.x + root.minX + var canvasY = mouse.y + root.minY + if (root.distanceToCurve(canvasX, canvasY) >= 10) { + mouse.accepted = false + } + } + + onClicked: (mouse) => { + var canvasX = mouse.x + root.minX + var canvasY = mouse.y + root.minY + if (root.distanceToCurve(canvasX, canvasY) < 10) { + graph.forceActiveFocus() + var additive = (mouse.modifiers & Qt.ControlModifier) + graph.selectConnection(sourceNodeId, sourcePortIndex, destNodeId, destPortIndex, additive) + } else { + mouse.accepted = false + } + } + + cursorShape: isOverCurve ? Qt.PointingHandCursor : Qt.ArrowCursor + } + + function distanceToCurve(px, py) { + var minDist = 999999 + var cp1x = startPos.x + Math.abs(endPos.x - startPos.x) * 0.5 + var cp1y = startPos.y + var cp2x = endPos.x - Math.abs(endPos.x - startPos.x) * 0.5 + var cp2y = endPos.y + + for (var t = 0; t <= 1; t += 0.02) { + var bx = bezierPoint(startPos.x, cp1x, cp2x, endPos.x, t) + var by = bezierPoint(startPos.y, cp1y, cp2y, endPos.y, t) + var dist = Math.sqrt((px - bx) * (px - bx) + (py - by) * (py - by)) + if (dist < minDist) minDist = dist + } + return minDist + } + + function bezierPoint(p0, p1, p2, p3, t) { + var u = 1 - t + return u*u*u*p0 + 3*u*u*t*p1 + 3*u*t*t*p2 + t*t*t*p3 + } + + // Selection outline (behind the main line) + Shape { + anchors.fill: parent + visible: root.selected + + ShapePath { + strokeWidth: 7 + strokeColor: "#4a9eff" + fillColor: "transparent" + + startX: root.startPos.x + startY: root.startPos.y + + PathCubic { + x: root.endPos.x + y: root.endPos.y + control1X: root.startPos.x + Math.abs(root.endPos.x - root.startPos.x) * 0.5 + control1Y: root.startPos.y + control2X: root.endPos.x - Math.abs(root.endPos.x - root.startPos.x) * 0.5 + control2Y: root.endPos.y + } + } + } + + // Visual connection line + Shape { + anchors.fill: parent - PathCubic { - x: root.endPos.x - y: root.endPos.y - control1X: root.startPos.x + Math.abs(root.endPos.x - root.startPos.x) * 0.5 - control1Y: root.startPos.y - control2X: root.endPos.x - Math.abs(root.endPos.x - root.startPos.x) * 0.5 - control2Y: root.endPos.y + ShapePath { + strokeWidth: root.hovered ? 3.5 : 3 + strokeColor: root.hovered ? "#ffffff" : "#eeeeee" + fillColor: "transparent" + + startX: root.startPos.x + startY: root.startPos.y + + PathCubic { + x: root.endPos.x + y: root.endPos.y + control1X: root.startPos.x + Math.abs(root.endPos.x - root.startPos.x) * 0.5 + control1Y: root.startPos.y + control2X: root.endPos.x - Math.abs(root.endPos.x - root.startPos.x) * 0.5 + control2Y: root.endPos.y + } } } } diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index fa04dbf34..c7b4c6af8 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -45,6 +45,7 @@ Item { function selectNode(nodeId, additive) { if (!additive) { selectedNodeIds = {} + clearConnectionSelection() } if (!selectedNodeIds.hasOwnProperty(nodeId)) { selectedNodeIds[nodeId] = true @@ -75,6 +76,7 @@ Item { selectedNodeIds = {} selectionVersion++ selectionChanged() + clearConnectionSelection() } function selectNodesInRect(rect) { @@ -91,6 +93,56 @@ Item { selectionChanged() } + function selectConnectionsInRect(rect) { + if (!graphModel || !graphModel.connections) return + + var connModel = graphModel.connections + for (var i = 0; i < connModel.rowCount(); i++) { + var idx = connModel.index(i, 0) + var srcNodeId = connModel.data(idx, 258) // SourceNodeIdRole + var srcPortIdx = connModel.data(idx, 259) // SourcePortIndexRole + var dstNodeId = connModel.data(idx, 260) // DestNodeIdRole + var dstPortIdx = connModel.data(idx, 261) // DestPortIndexRole + + var srcNode = nodeItems[srcNodeId] + var dstNode = nodeItems[dstNodeId] + + if (srcNode && dstNode && srcNode.completed && dstNode.completed) { + var startPos = srcNode.getPortPos(1, srcPortIdx) + var endPos = dstNode.getPortPos(0, dstPortIdx) + + if (curveIntersectsRect(startPos, endPos, rect)) { + selectedConnections.push({ + outNodeId: srcNodeId, + outPortIndex: srcPortIdx, + inNodeId: dstNodeId, + inPortIndex: dstPortIdx + }) + } + } + } + connectionSelectionChanged() + } + + function curveIntersectsRect(startPos, endPos, rect) { + var cp1x = startPos.x + Math.abs(endPos.x - startPos.x) * 0.5 + var cp1y = startPos.y + var cp2x = endPos.x - Math.abs(endPos.x - startPos.x) * 0.5 + var cp2y = endPos.y + + for (var t = 0; t <= 1; t += 0.05) { + var u = 1 - t + var bx = u*u*u*startPos.x + 3*u*u*t*cp1x + 3*u*t*t*cp2x + t*t*t*endPos.x + var by = u*u*u*startPos.y + 3*u*u*t*cp1y + 3*u*t*t*cp2y + t*t*t*endPos.y + + if (bx >= rect.x && bx <= rect.x + rect.width && + by >= rect.y && by <= rect.y + rect.height) { + return true + } + } + return false + } + function rectsIntersect(r1, r2) { return !(r2.x > r1.x + r1.width || r2.x + r2.width < r1.x || @@ -102,6 +154,73 @@ Item { return Object.keys(selectedNodeIds).map(function(id) { return parseInt(id) }) } + // Connection selection management + property var selectedConnections: [] + + signal connectionSelectionChanged() + + function isConnectionSelected(outNodeId, outPortIndex, inNodeId, inPortIndex) { + for (var i = 0; i < selectedConnections.length; i++) { + var c = selectedConnections[i] + if (c.outNodeId === outNodeId && c.outPortIndex === outPortIndex && + c.inNodeId === inNodeId && c.inPortIndex === inPortIndex) { + return true + } + } + return false + } + + function selectConnection(outNodeId, outPortIndex, inNodeId, inPortIndex, additive) { + if (!additive) { + selectedConnections = [] + clearSelection() + } + selectedConnections.push({ + outNodeId: outNodeId, + outPortIndex: outPortIndex, + inNodeId: inNodeId, + inPortIndex: inPortIndex + }) + connectionSelectionChanged() + } + + function clearConnectionSelection() { + selectedConnections = [] + connectionSelectionChanged() + } + + function deleteSelectedConnections() { + for (var i = 0; i < selectedConnections.length; i++) { + var c = selectedConnections[i] + graphModel.removeConnection(c.outNodeId, c.outPortIndex, c.inNodeId, c.inPortIndex) + } + selectedConnections = [] + connectionSelectionChanged() + } + + function deleteSelectedNodes() { + var ids = getSelectedNodeIds() + for (var i = 0; i < ids.length; i++) { + graphModel.removeNode(ids[i]) + delete nodeItems[ids[i]] + } + clearSelection() + } + + function deleteSelected() { + deleteSelectedConnections() + deleteSelectedNodes() + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Delete || event.key === Qt.Key_Backspace || event.key === Qt.Key_X) { + deleteSelected() + event.accepted = true + } + } + + focus: true + // Marquee selection property bool isMarqueeSelecting: false property point marqueeStart: Qt.point(0, 0) @@ -223,14 +342,16 @@ Item { onReleased: (mouse) => { if (root.isMarqueeSelecting) { - // Select nodes in marquee rect + // Select nodes and connections in marquee rect var x = Math.min(root.marqueeStart.x, root.marqueeEnd.x) var y = Math.min(root.marqueeStart.y, root.marqueeEnd.y) var w = Math.abs(root.marqueeEnd.x - root.marqueeStart.x) var h = Math.abs(root.marqueeEnd.y - root.marqueeStart.y) if (w > 5 || h > 5) { - root.selectNodesInRect(Qt.rect(x, y, w, h)) + var rect = Qt.rect(x, y, w, h) + root.selectNodesInRect(rect) + root.selectConnectionsInRect(rect) } root.isMarqueeSelecting = false } From 944e2b05dbe8e56e6504209ad7b6552fcc703f3a Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Tue, 2 Dec 2025 23:18:18 -0300 Subject: [PATCH 26/33] feat: colors type --- examples/qml_calculator/BooleanData.hpp | 25 ++ .../qml_calculator/BooleanDisplayModel.hpp | 59 ++++ examples/qml_calculator/CMakeLists.txt | 13 + examples/qml_calculator/DivideModel.hpp | 59 ++++ examples/qml_calculator/FormatNumberModel.hpp | 86 +++++ examples/qml_calculator/GreaterThanModel.hpp | 75 +++++ examples/qml_calculator/IntegerData.hpp | 25 ++ .../qml_calculator/IntegerDisplayModel.hpp | 54 +++ .../qml_calculator/IntegerSourceModel.hpp | 61 ++++ examples/qml_calculator/MultiplyModel.hpp | 59 ++++ examples/qml_calculator/StringData.hpp | 25 ++ .../qml_calculator/StringDisplayModel.hpp | 54 +++ examples/qml_calculator/SubtractModel.hpp | 59 ++++ examples/qml_calculator/ToIntegerModel.hpp | 62 ++++ examples/qml_calculator/main.cpp | 24 ++ examples/qml_calculator/main.qml | 316 ++++++++++++++++-- include/QtNodes/qml/QuickGraphModel.hpp | 2 + resources/qml/Connection.qml | 5 +- resources/qml/Node.qml | 24 +- resources/qml/NodeGraph.qml | 22 ++ src/qml/QuickGraphModel.cpp | 28 ++ 21 files changed, 1100 insertions(+), 37 deletions(-) create mode 100644 examples/qml_calculator/BooleanData.hpp create mode 100644 examples/qml_calculator/BooleanDisplayModel.hpp create mode 100644 examples/qml_calculator/DivideModel.hpp create mode 100644 examples/qml_calculator/FormatNumberModel.hpp create mode 100644 examples/qml_calculator/GreaterThanModel.hpp create mode 100644 examples/qml_calculator/IntegerData.hpp create mode 100644 examples/qml_calculator/IntegerDisplayModel.hpp create mode 100644 examples/qml_calculator/IntegerSourceModel.hpp create mode 100644 examples/qml_calculator/MultiplyModel.hpp create mode 100644 examples/qml_calculator/StringData.hpp create mode 100644 examples/qml_calculator/StringDisplayModel.hpp create mode 100644 examples/qml_calculator/SubtractModel.hpp create mode 100644 examples/qml_calculator/ToIntegerModel.hpp diff --git a/examples/qml_calculator/BooleanData.hpp b/examples/qml_calculator/BooleanData.hpp new file mode 100644 index 000000000..f3473b728 --- /dev/null +++ b/examples/qml_calculator/BooleanData.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +using QtNodes::NodeData; +using QtNodes::NodeDataType; + +class BooleanData : public NodeData +{ +public: + BooleanData() + : _value(false) + {} + + BooleanData(bool value) + : _value(value) + {} + + NodeDataType type() const override { return NodeDataType{"boolean", "Boolean"}; } + + bool value() const { return _value; } + +private: + bool _value; +}; diff --git a/examples/qml_calculator/BooleanDisplayModel.hpp b/examples/qml_calculator/BooleanDisplayModel.hpp new file mode 100644 index 000000000..dfde860b8 --- /dev/null +++ b/examples/qml_calculator/BooleanDisplayModel.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include "BooleanData.hpp" + +using QtNodes::NodeData; +using QtNodes::NodeDataType; +using QtNodes::NodeDelegateModel; +using QtNodes::PortIndex; +using QtNodes::PortType; + +class BooleanDisplayModel : public NodeDelegateModel +{ + Q_OBJECT + Q_PROPERTY(QString displayedText READ displayedText NOTIFY displayedTextChanged) + Q_PROPERTY(bool value READ value NOTIFY displayedTextChanged) + +public: + QString caption() const override { return QStringLiteral("Bool Display"); } + QString name() const override { return QStringLiteral("BooleanDisplay"); } + + unsigned int nPorts(PortType portType) const override + { + return (portType == PortType::In) ? 1 : 0; + } + + NodeDataType dataType(PortType, PortIndex) const override + { + return BooleanData{}.type(); + } + + void setInData(std::shared_ptr data, PortIndex) override + { + auto boolData = std::dynamic_pointer_cast(data); + if (boolData) { + _value = boolData->value(); + _displayedText = _value ? "TRUE" : "FALSE"; + } else { + _value = false; + _displayedText = "..."; + } + Q_EMIT displayedTextChanged(); + } + + std::shared_ptr outData(PortIndex) override { return nullptr; } + + QWidget *embeddedWidget() override { return nullptr; } + + QString displayedText() const { return _displayedText; } + bool value() const { return _value; } + +Q_SIGNALS: + void displayedTextChanged(); + +private: + QString _displayedText = "..."; + bool _value = false; +}; diff --git a/examples/qml_calculator/CMakeLists.txt b/examples/qml_calculator/CMakeLists.txt index 4251de40f..671580419 100644 --- a/examples/qml_calculator/CMakeLists.txt +++ b/examples/qml_calculator/CMakeLists.txt @@ -6,7 +6,20 @@ add_executable(${TARGET_NAME} QmlNumberSourceDataModel.hpp QmlNumberDisplayDataModel.hpp AdditionModel.hpp + MultiplyModel.hpp + SubtractModel.hpp + DivideModel.hpp + FormatNumberModel.hpp + StringDisplayModel.hpp + IntegerSourceModel.hpp + IntegerDisplayModel.hpp + ToIntegerModel.hpp + GreaterThanModel.hpp + BooleanDisplayModel.hpp DecimalData.hpp + StringData.hpp + IntegerData.hpp + BooleanData.hpp ) target_link_libraries(${TARGET_NAME} diff --git a/examples/qml_calculator/DivideModel.hpp b/examples/qml_calculator/DivideModel.hpp new file mode 100644 index 000000000..65baf2666 --- /dev/null +++ b/examples/qml_calculator/DivideModel.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include "DecimalData.hpp" + +using QtNodes::NodeData; +using QtNodes::NodeDataType; +using QtNodes::NodeDelegateModel; +using QtNodes::PortIndex; +using QtNodes::PortType; + +class DivideModel : public NodeDelegateModel +{ + Q_OBJECT + +public: + QString caption() const override { return QStringLiteral("Divide"); } + QString name() const override { return QStringLiteral("Divide"); } + + unsigned int nPorts(PortType portType) const override + { + return (portType == PortType::In) ? 2 : 1; + } + + NodeDataType dataType(PortType, PortIndex) const override + { + return DecimalData{}.type(); + } + + void setInData(std::shared_ptr data, PortIndex portIndex) override + { + auto numberData = std::dynamic_pointer_cast(data); + if (portIndex == 0) { + _number1 = numberData; + } else { + _number2 = numberData; + } + compute(); + } + + std::shared_ptr outData(PortIndex) override { return _result; } + + QWidget *embeddedWidget() override { return nullptr; } + +private: + void compute() + { + if (_number1 && _number2 && _number2->number() != 0.0) { + _result = std::make_shared(_number1->number() / _number2->number()); + } else { + _result.reset(); + } + Q_EMIT dataUpdated(0); + } + + std::shared_ptr _number1; + std::shared_ptr _number2; + std::shared_ptr _result; +}; diff --git a/examples/qml_calculator/FormatNumberModel.hpp b/examples/qml_calculator/FormatNumberModel.hpp new file mode 100644 index 000000000..f4d1b26c9 --- /dev/null +++ b/examples/qml_calculator/FormatNumberModel.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include "DecimalData.hpp" +#include "StringData.hpp" + +using QtNodes::NodeData; +using QtNodes::NodeDataType; +using QtNodes::NodeDelegateModel; +using QtNodes::PortIndex; +using QtNodes::PortType; + +class FormatNumberModel : public NodeDelegateModel +{ + Q_OBJECT + Q_PROPERTY(QString formatPattern READ formatPattern WRITE setFormatPattern NOTIFY formatPatternChanged) + Q_PROPERTY(QString formattedText READ formattedText NOTIFY formattedTextChanged) + +public: + FormatNumberModel() + : _formatPattern("Result: %1") + {} + + QString caption() const override { return QStringLiteral("Format"); } + QString name() const override { return QStringLiteral("FormatNumber"); } + + unsigned int nPorts(PortType portType) const override + { + return 1; + } + + NodeDataType dataType(PortType portType, PortIndex) const override + { + if (portType == PortType::In) { + return DecimalData{}.type(); + } + return StringData{}.type(); + } + + void setInData(std::shared_ptr data, PortIndex) override + { + _inputNumber = std::dynamic_pointer_cast(data); + compute(); + } + + std::shared_ptr outData(PortIndex) override { return _result; } + + QWidget *embeddedWidget() override { return nullptr; } + + QString formatPattern() const { return _formatPattern; } + + void setFormatPattern(const QString &pattern) + { + if (_formatPattern != pattern) { + _formatPattern = pattern; + Q_EMIT formatPatternChanged(); + compute(); + } + } + + QString formattedText() const { return _formattedText; } + +Q_SIGNALS: + void formatPatternChanged(); + void formattedTextChanged(); + +private: + void compute() + { + if (_inputNumber) { + _formattedText = _formatPattern.arg(_inputNumber->number(), 0, 'f', 2); + _result = std::make_shared(_formattedText); + } else { + _formattedText = ""; + _result.reset(); + } + Q_EMIT formattedTextChanged(); + Q_EMIT dataUpdated(0); + } + + QString _formatPattern; + QString _formattedText; + std::shared_ptr _inputNumber; + std::shared_ptr _result; +}; diff --git a/examples/qml_calculator/GreaterThanModel.hpp b/examples/qml_calculator/GreaterThanModel.hpp new file mode 100644 index 000000000..520c41392 --- /dev/null +++ b/examples/qml_calculator/GreaterThanModel.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include "DecimalData.hpp" +#include "BooleanData.hpp" + +using QtNodes::NodeData; +using QtNodes::NodeDataType; +using QtNodes::NodeDelegateModel; +using QtNodes::PortIndex; +using QtNodes::PortType; + +class GreaterThanModel : public NodeDelegateModel +{ + Q_OBJECT + Q_PROPERTY(QString resultText READ resultText NOTIFY resultChanged) + +public: + QString caption() const override { return QStringLiteral("A > B"); } + QString name() const override { return QStringLiteral("GreaterThan"); } + + unsigned int nPorts(PortType portType) const override + { + return (portType == PortType::In) ? 2 : 1; + } + + NodeDataType dataType(PortType portType, PortIndex) const override + { + if (portType == PortType::In) { + return DecimalData{}.type(); + } + return BooleanData{}.type(); + } + + void setInData(std::shared_ptr data, PortIndex portIndex) override + { + auto numberData = std::dynamic_pointer_cast(data); + if (portIndex == 0) { + _number1 = numberData; + } else { + _number2 = numberData; + } + compute(); + } + + std::shared_ptr outData(PortIndex) override { return _result; } + + QWidget *embeddedWidget() override { return nullptr; } + + QString resultText() const { return _resultText; } + +Q_SIGNALS: + void resultChanged(); + +private: + void compute() + { + if (_number1 && _number2) { + bool val = _number1->number() > _number2->number(); + _result = std::make_shared(val); + _resultText = val ? "TRUE" : "FALSE"; + } else { + _result.reset(); + _resultText = "?"; + } + Q_EMIT resultChanged(); + Q_EMIT dataUpdated(0); + } + + std::shared_ptr _number1; + std::shared_ptr _number2; + std::shared_ptr _result; + QString _resultText = "?"; +}; diff --git a/examples/qml_calculator/IntegerData.hpp b/examples/qml_calculator/IntegerData.hpp new file mode 100644 index 000000000..ba7cd2dfc --- /dev/null +++ b/examples/qml_calculator/IntegerData.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +using QtNodes::NodeData; +using QtNodes::NodeDataType; + +class IntegerData : public NodeData +{ +public: + IntegerData() + : _value(0) + {} + + IntegerData(int value) + : _value(value) + {} + + NodeDataType type() const override { return NodeDataType{"integer", "Integer"}; } + + int value() const { return _value; } + +private: + int _value; +}; diff --git a/examples/qml_calculator/IntegerDisplayModel.hpp b/examples/qml_calculator/IntegerDisplayModel.hpp new file mode 100644 index 000000000..8762813bd --- /dev/null +++ b/examples/qml_calculator/IntegerDisplayModel.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include "IntegerData.hpp" + +using QtNodes::NodeData; +using QtNodes::NodeDataType; +using QtNodes::NodeDelegateModel; +using QtNodes::PortIndex; +using QtNodes::PortType; + +class IntegerDisplayModel : public NodeDelegateModel +{ + Q_OBJECT + Q_PROPERTY(QString displayedText READ displayedText NOTIFY displayedTextChanged) + +public: + QString caption() const override { return QStringLiteral("Int Display"); } + QString name() const override { return QStringLiteral("IntegerDisplay"); } + + unsigned int nPorts(PortType portType) const override + { + return (portType == PortType::In) ? 1 : 0; + } + + NodeDataType dataType(PortType, PortIndex) const override + { + return IntegerData{}.type(); + } + + void setInData(std::shared_ptr data, PortIndex) override + { + auto intData = std::dynamic_pointer_cast(data); + if (intData) { + _displayedText = QString::number(intData->value()); + } else { + _displayedText = "..."; + } + Q_EMIT displayedTextChanged(); + } + + std::shared_ptr outData(PortIndex) override { return nullptr; } + + QWidget *embeddedWidget() override { return nullptr; } + + QString displayedText() const { return _displayedText; } + +Q_SIGNALS: + void displayedTextChanged(); + +private: + QString _displayedText = "..."; +}; diff --git a/examples/qml_calculator/IntegerSourceModel.hpp b/examples/qml_calculator/IntegerSourceModel.hpp new file mode 100644 index 000000000..b17f8cc97 --- /dev/null +++ b/examples/qml_calculator/IntegerSourceModel.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include "IntegerData.hpp" + +using QtNodes::NodeData; +using QtNodes::NodeDataType; +using QtNodes::NodeDelegateModel; +using QtNodes::PortIndex; +using QtNodes::PortType; + +class IntegerSourceModel : public NodeDelegateModel +{ + Q_OBJECT + Q_PROPERTY(int number READ number WRITE setNumber NOTIFY numberChanged) + +public: + IntegerSourceModel() + : _number(0) + {} + + QString caption() const override { return QStringLiteral("Integer"); } + QString name() const override { return QStringLiteral("IntegerSource"); } + + unsigned int nPorts(PortType portType) const override + { + return (portType == PortType::Out) ? 1 : 0; + } + + NodeDataType dataType(PortType, PortIndex) const override + { + return IntegerData{}.type(); + } + + void setInData(std::shared_ptr, PortIndex) override {} + + std::shared_ptr outData(PortIndex) override + { + return std::make_shared(_number); + } + + QWidget *embeddedWidget() override { return nullptr; } + + int number() const { return _number; } + + void setNumber(int n) + { + if (_number != n) { + _number = n; + Q_EMIT numberChanged(); + Q_EMIT dataUpdated(0); + } + } + +Q_SIGNALS: + void numberChanged(); + +private: + int _number; +}; diff --git a/examples/qml_calculator/MultiplyModel.hpp b/examples/qml_calculator/MultiplyModel.hpp new file mode 100644 index 000000000..ffbf365a2 --- /dev/null +++ b/examples/qml_calculator/MultiplyModel.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include "DecimalData.hpp" + +using QtNodes::NodeData; +using QtNodes::NodeDataType; +using QtNodes::NodeDelegateModel; +using QtNodes::PortIndex; +using QtNodes::PortType; + +class MultiplyModel : public NodeDelegateModel +{ + Q_OBJECT + +public: + QString caption() const override { return QStringLiteral("Multiply"); } + QString name() const override { return QStringLiteral("Multiply"); } + + unsigned int nPorts(PortType portType) const override + { + return (portType == PortType::In) ? 2 : 1; + } + + NodeDataType dataType(PortType, PortIndex) const override + { + return DecimalData{}.type(); + } + + void setInData(std::shared_ptr data, PortIndex portIndex) override + { + auto numberData = std::dynamic_pointer_cast(data); + if (portIndex == 0) { + _number1 = numberData; + } else { + _number2 = numberData; + } + compute(); + } + + std::shared_ptr outData(PortIndex) override { return _result; } + + QWidget *embeddedWidget() override { return nullptr; } + +private: + void compute() + { + if (_number1 && _number2) { + _result = std::make_shared(_number1->number() * _number2->number()); + } else { + _result.reset(); + } + Q_EMIT dataUpdated(0); + } + + std::shared_ptr _number1; + std::shared_ptr _number2; + std::shared_ptr _result; +}; diff --git a/examples/qml_calculator/StringData.hpp b/examples/qml_calculator/StringData.hpp new file mode 100644 index 000000000..f1c2ad2de --- /dev/null +++ b/examples/qml_calculator/StringData.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +using QtNodes::NodeData; +using QtNodes::NodeDataType; + +class StringData : public NodeData +{ +public: + StringData() + : _text("") + {} + + StringData(QString const &text) + : _text(text) + {} + + NodeDataType type() const override { return NodeDataType{"string", "String"}; } + + QString text() const { return _text; } + +private: + QString _text; +}; diff --git a/examples/qml_calculator/StringDisplayModel.hpp b/examples/qml_calculator/StringDisplayModel.hpp new file mode 100644 index 000000000..bd84ab8d6 --- /dev/null +++ b/examples/qml_calculator/StringDisplayModel.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include "StringData.hpp" + +using QtNodes::NodeData; +using QtNodes::NodeDataType; +using QtNodes::NodeDelegateModel; +using QtNodes::PortIndex; +using QtNodes::PortType; + +class StringDisplayModel : public NodeDelegateModel +{ + Q_OBJECT + Q_PROPERTY(QString displayedText READ displayedText NOTIFY displayedTextChanged) + +public: + QString caption() const override { return QStringLiteral("Text Display"); } + QString name() const override { return QStringLiteral("StringDisplay"); } + + unsigned int nPorts(PortType portType) const override + { + return (portType == PortType::In) ? 1 : 0; + } + + NodeDataType dataType(PortType, PortIndex) const override + { + return StringData{}.type(); + } + + void setInData(std::shared_ptr data, PortIndex) override + { + auto stringData = std::dynamic_pointer_cast(data); + if (stringData) { + _displayedText = stringData->text(); + } else { + _displayedText = ""; + } + Q_EMIT displayedTextChanged(); + } + + std::shared_ptr outData(PortIndex) override { return nullptr; } + + QWidget *embeddedWidget() override { return nullptr; } + + QString displayedText() const { return _displayedText; } + +Q_SIGNALS: + void displayedTextChanged(); + +private: + QString _displayedText; +}; diff --git a/examples/qml_calculator/SubtractModel.hpp b/examples/qml_calculator/SubtractModel.hpp new file mode 100644 index 000000000..e99701b63 --- /dev/null +++ b/examples/qml_calculator/SubtractModel.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include "DecimalData.hpp" + +using QtNodes::NodeData; +using QtNodes::NodeDataType; +using QtNodes::NodeDelegateModel; +using QtNodes::PortIndex; +using QtNodes::PortType; + +class SubtractModel : public NodeDelegateModel +{ + Q_OBJECT + +public: + QString caption() const override { return QStringLiteral("Subtract"); } + QString name() const override { return QStringLiteral("Subtract"); } + + unsigned int nPorts(PortType portType) const override + { + return (portType == PortType::In) ? 2 : 1; + } + + NodeDataType dataType(PortType, PortIndex) const override + { + return DecimalData{}.type(); + } + + void setInData(std::shared_ptr data, PortIndex portIndex) override + { + auto numberData = std::dynamic_pointer_cast(data); + if (portIndex == 0) { + _number1 = numberData; + } else { + _number2 = numberData; + } + compute(); + } + + std::shared_ptr outData(PortIndex) override { return _result; } + + QWidget *embeddedWidget() override { return nullptr; } + +private: + void compute() + { + if (_number1 && _number2) { + _result = std::make_shared(_number1->number() - _number2->number()); + } else { + _result.reset(); + } + Q_EMIT dataUpdated(0); + } + + std::shared_ptr _number1; + std::shared_ptr _number2; + std::shared_ptr _result; +}; diff --git a/examples/qml_calculator/ToIntegerModel.hpp b/examples/qml_calculator/ToIntegerModel.hpp new file mode 100644 index 000000000..0de99f0f9 --- /dev/null +++ b/examples/qml_calculator/ToIntegerModel.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include "DecimalData.hpp" +#include "IntegerData.hpp" + +using QtNodes::NodeData; +using QtNodes::NodeDataType; +using QtNodes::NodeDelegateModel; +using QtNodes::PortIndex; +using QtNodes::PortType; + +class ToIntegerModel : public NodeDelegateModel +{ + Q_OBJECT + Q_PROPERTY(int resultValue READ resultValue NOTIFY resultChanged) + +public: + QString caption() const override { return QStringLiteral("To Int"); } + QString name() const override { return QStringLiteral("ToInteger"); } + + unsigned int nPorts(PortType portType) const override + { + return 1; + } + + NodeDataType dataType(PortType portType, PortIndex) const override + { + if (portType == PortType::In) { + return DecimalData{}.type(); + } + return IntegerData{}.type(); + } + + void setInData(std::shared_ptr data, PortIndex) override + { + auto decimal = std::dynamic_pointer_cast(data); + if (decimal) { + _resultValue = static_cast(decimal->number()); + _result = std::make_shared(_resultValue); + } else { + _resultValue = 0; + _result.reset(); + } + Q_EMIT resultChanged(); + Q_EMIT dataUpdated(0); + } + + std::shared_ptr outData(PortIndex) override { return _result; } + + QWidget *embeddedWidget() override { return nullptr; } + + int resultValue() const { return _resultValue; } + +Q_SIGNALS: + void resultChanged(); + +private: + int _resultValue = 0; + std::shared_ptr _result; +}; diff --git a/examples/qml_calculator/main.cpp b/examples/qml_calculator/main.cpp index fb3e6395c..c6c676ec9 100644 --- a/examples/qml_calculator/main.cpp +++ b/examples/qml_calculator/main.cpp @@ -11,6 +11,16 @@ #include "QmlNumberSourceDataModel.hpp" #include "QmlNumberDisplayDataModel.hpp" #include "AdditionModel.hpp" +#include "MultiplyModel.hpp" +#include "SubtractModel.hpp" +#include "DivideModel.hpp" +#include "FormatNumberModel.hpp" +#include "StringDisplayModel.hpp" +#include "IntegerSourceModel.hpp" +#include "IntegerDisplayModel.hpp" +#include "ToIntegerModel.hpp" +#include "GreaterThanModel.hpp" +#include "BooleanDisplayModel.hpp" using QtNodes::NodeDelegateModelRegistry; using QtNodes::QuickGraphModel; @@ -18,9 +28,23 @@ using QtNodes::QuickGraphModel; static std::shared_ptr registerDataModels() { auto ret = std::make_shared(); + // Decimal nodes ret->registerModel("NumberSource"); ret->registerModel("NumberDisplay"); ret->registerModel("Addition"); + ret->registerModel("Multiply"); + ret->registerModel("Subtract"); + ret->registerModel("Divide"); + // String nodes + ret->registerModel("FormatNumber"); + ret->registerModel("StringDisplay"); + // Integer nodes + ret->registerModel("IntegerSource"); + ret->registerModel("IntegerDisplay"); + ret->registerModel("ToInteger"); + // Boolean nodes + ret->registerModel("GreaterThan"); + ret->registerModel("BooleanDisplay"); return ret; } diff --git a/examples/qml_calculator/main.qml b/examples/qml_calculator/main.qml index 98ff7d750..d8e50c3bd 100644 --- a/examples/qml_calculator/main.qml +++ b/examples/qml_calculator/main.qml @@ -1,52 +1,170 @@ import QtQuick 2.15 import QtQuick.Window 2.15 import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 import QtNodes 1.0 Window { visible: true - width: 1024 - height: 768 - title: "QML Calculator" + width: 1280 + height: 800 + title: "QML Calculator - Extended" - // Context property set from C++ property QuickGraphModel model: _graphModel Column { anchors.fill: parent - Row { - height: 40 - spacing: 10 - padding: 5 - Button { text: "Add Source"; onClicked: model.addNode("NumberSource") } - Button { text: "Add Display"; onClicked: model.addNode("NumberDisplay") } - Button { text: "Add Addition"; onClicked: model.addNode("Addition") } + // Toolbar with node buttons + Rectangle { + width: parent.width + height: 50 + color: "#3c3c3c" + + RowLayout { + anchors.fill: parent + anchors.margins: 5 + spacing: 10 + + Label { + text: "Numbers:" + color: "#aaa" + font.bold: true + } + Button { + text: "Number" + onClicked: model.addNode("NumberSource") + palette.buttonText: "#4CAF50" + } + + Rectangle { width: 1; height: 30; color: "#555" } + + Label { + text: "Math:" + color: "#aaa" + font.bold: true + } + Button { text: "Add"; onClicked: model.addNode("Addition") } + Button { text: "Subtract"; onClicked: model.addNode("Subtract") } + Button { text: "Multiply"; onClicked: model.addNode("Multiply") } + Button { text: "Divide"; onClicked: model.addNode("Divide") } + + Rectangle { width: 1; height: 30; color: "#555" } + + Label { + text: "String:" + color: "#aaa" + font.bold: true + } + Button { + text: "Format" + onClicked: model.addNode("FormatNumber") + palette.buttonText: "#FF9800" + } + Button { + text: "Text Display" + onClicked: model.addNode("StringDisplay") + palette.buttonText: "#FF9800" + } + + Rectangle { width: 1; height: 30; color: "#555" } + + Label { + text: "Integer:" + color: "#aaa" + font.bold: true + } + Button { + text: "Int" + onClicked: model.addNode("IntegerSource") + palette.buttonText: "#2196F3" + } + Button { + text: "To Int" + onClicked: model.addNode("ToInteger") + palette.buttonText: "#2196F3" + } + + Rectangle { width: 1; height: 30; color: "#555" } + + Label { + text: "Logic:" + color: "#aaa" + font.bold: true + } + Button { + text: "A > B" + onClicked: model.addNode("GreaterThan") + palette.buttonText: "#9C27B0" + } + + Rectangle { width: 1; height: 30; color: "#555" } + + Label { + text: "Display:" + color: "#aaa" + font.bold: true + } + Button { + text: "Decimal" + onClicked: model.addNode("NumberDisplay") + palette.buttonText: "#4CAF50" + } + Button { + text: "Int" + onClicked: model.addNode("IntegerDisplay") + palette.buttonText: "#2196F3" + } + Button { + text: "Bool" + onClicked: model.addNode("BooleanDisplay") + palette.buttonText: "#9C27B0" + } + + Item { Layout.fillWidth: true } + } } NodeGraph { width: parent.width - height: parent.height - 40 + height: parent.height - 50 graphModel: model Component.onCompleted: { - var n1 = model.addNode("NumberSource") - var n2 = model.addNode("NumberSource") - var n3 = model.addNode("Addition") - var n4 = model.addNode("NumberDisplay") + // Create a demo graph: (5 + 3) * 2 = 16, formatted as text + var num1 = model.addNode("NumberSource") + var num2 = model.addNode("NumberSource") + var num3 = model.addNode("NumberSource") + var add = model.addNode("Addition") + var mult = model.addNode("Multiply") + var numDisplay = model.addNode("NumberDisplay") + var format = model.addNode("FormatNumber") + var textDisplay = model.addNode("StringDisplay") - if (n1 >= 0 && n2 >= 0 && n3 >= 0 && n4 >= 0) { - model.nodes.moveNode(n1, 100, 100) - model.nodes.moveNode(n2, 100, 250) - model.nodes.moveNode(n3, 400, 175) - model.nodes.moveNode(n4, 700, 175) - - // Connect Source 1 to Addition In 0 - model.addConnection(n1, 0, n3, 0) - // Connect Source 2 to Addition In 1 - model.addConnection(n2, 0, n3, 1) - // Connect Addition Out 0 to Display In 0 - model.addConnection(n3, 0, n4, 0) + if (num1 >= 0) { + model.nodes.moveNode(num1, 50, 80) + model.nodes.moveNode(num2, 50, 200) + model.nodes.moveNode(num3, 50, 350) + model.nodes.moveNode(add, 250, 130) + model.nodes.moveNode(mult, 450, 200) + model.nodes.moveNode(numDisplay, 700, 150) + model.nodes.moveNode(format, 700, 280) + model.nodes.moveNode(textDisplay, 950, 280) + + // (num1 + num2) -> add + model.addConnection(num1, 0, add, 0) + model.addConnection(num2, 0, add, 1) + + // add * num3 -> mult + model.addConnection(add, 0, mult, 0) + model.addConnection(num3, 0, mult, 1) + + // mult -> numDisplay + model.addConnection(mult, 0, numDisplay, 0) + + // mult -> format -> textDisplay + model.addConnection(mult, 0, format, 0) + model.addConnection(format, 0, textDisplay, 0) } } @@ -55,6 +173,7 @@ Window { property var delegateModel property string nodeType + // NumberSource - editable number input TextField { anchors.centerIn: parent width: parent.width @@ -67,23 +186,156 @@ Window { if (activeFocus) selectAll() } color: "black" - background: Rectangle { color: "white" } + horizontalAlignment: Text.AlignHCenter + background: Rectangle { color: "white"; radius: 3 } } + // NumberDisplay - shows decimal result Text { anchors.centerIn: parent visible: nodeType === "NumberDisplay" text: (delegateModel && delegateModel.displayedText !== undefined) ? delegateModel.displayedText : "..." - color: "white" - font.pixelSize: 20 + color: "#4CAF50" + font.pixelSize: 18 + font.bold: true } + // Math operation symbols Text { anchors.centerIn: parent visible: nodeType === "Addition" text: "+" color: "white" - font.pixelSize: 40 + font.pixelSize: 36 + font.bold: true + } + + Text { + anchors.centerIn: parent + visible: nodeType === "Subtract" + text: "−" + color: "white" + font.pixelSize: 36 + font.bold: true + } + + Text { + anchors.centerIn: parent + visible: nodeType === "Multiply" + text: "×" + color: "white" + font.pixelSize: 36 + font.bold: true + } + + Text { + anchors.centerIn: parent + visible: nodeType === "Divide" + text: "÷" + color: "white" + font.pixelSize: 36 + font.bold: true + } + + // FormatNumber - editable format pattern + preview + Column { + anchors.fill: parent + anchors.margins: 2 + visible: nodeType === "FormatNumber" + spacing: 4 + + TextField { + width: parent.width + text: (delegateModel && delegateModel.formatPattern !== undefined) ? delegateModel.formatPattern : "Result: %1" + onEditingFinished: { + if (delegateModel) delegateModel.formatPattern = text + } + onActiveFocusChanged: { + if (activeFocus) selectAll() + } + color: "black" + font.pixelSize: 11 + background: Rectangle { color: "#ffe0b2"; radius: 2 } + placeholderText: "Format: %1" + } + + Text { + width: parent.width + text: (delegateModel && delegateModel.formattedText !== undefined) ? delegateModel.formattedText : "" + color: "#FF9800" + font.pixelSize: 10 + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + } + } + + // StringDisplay - shows formatted text result + Text { + anchors.centerIn: parent + width: parent.width - 10 + visible: nodeType === "StringDisplay" + text: (delegateModel && delegateModel.displayedText !== undefined) ? delegateModel.displayedText : "..." + color: "#FF9800" + font.pixelSize: 14 + font.bold: true + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + + // IntegerSource - editable integer input + TextField { + anchors.centerIn: parent + width: parent.width + visible: nodeType === "IntegerSource" + text: (delegateModel && delegateModel.number !== undefined) ? delegateModel.number.toString() : "0" + onEditingFinished: { + if (delegateModel) delegateModel.number = parseInt(text) + } + onActiveFocusChanged: { + if (activeFocus) selectAll() + } + color: "black" + horizontalAlignment: Text.AlignHCenter + background: Rectangle { color: "#bbdefb"; radius: 3 } + validator: IntValidator {} + } + + // IntegerDisplay + Text { + anchors.centerIn: parent + visible: nodeType === "IntegerDisplay" + text: (delegateModel && delegateModel.displayedText !== undefined) ? delegateModel.displayedText : "..." + color: "#2196F3" + font.pixelSize: 18 + font.bold: true + } + + // ToInteger - shows conversion result + Text { + anchors.centerIn: parent + visible: nodeType === "ToInteger" + text: (delegateModel && delegateModel.resultValue !== undefined) ? "→ " + delegateModel.resultValue : "→ ?" + color: "#2196F3" + font.pixelSize: 14 + } + + // GreaterThan - comparison result + Text { + anchors.centerIn: parent + visible: nodeType === "GreaterThan" + text: (delegateModel && delegateModel.resultText !== undefined) ? delegateModel.resultText : "?" + color: delegateModel && delegateModel.resultText === "TRUE" ? "#4CAF50" : "#f44336" + font.pixelSize: 16 + font.bold: true + } + + // BooleanDisplay + Text { + anchors.centerIn: parent + visible: nodeType === "BooleanDisplay" + text: (delegateModel && delegateModel.displayedText !== undefined) ? delegateModel.displayedText : "..." + color: delegateModel && delegateModel.value ? "#4CAF50" : "#f44336" + font.pixelSize: 18 font.bold: true } } diff --git a/include/QtNodes/qml/QuickGraphModel.hpp b/include/QtNodes/qml/QuickGraphModel.hpp index 22f791def..674ddbf04 100644 --- a/include/QtNodes/qml/QuickGraphModel.hpp +++ b/include/QtNodes/qml/QuickGraphModel.hpp @@ -36,6 +36,8 @@ class QuickGraphModel : public QObject Q_INVOKABLE void addConnection(int outNodeId, int outPortIndex, int inNodeId, int inPortIndex); Q_INVOKABLE void removeConnection(int outNodeId, int outPortIndex, int inNodeId, int inPortIndex); Q_INVOKABLE QVariantMap getConnectionAtInput(int nodeId, int portIndex); + Q_INVOKABLE QString getPortDataTypeId(int nodeId, int portType, int portIndex); + Q_INVOKABLE bool connectionPossible(int outNodeId, int outPortIndex, int inNodeId, int inPortIndex); private: std::shared_ptr _model; diff --git a/resources/qml/Connection.qml b/resources/qml/Connection.qml index 06333b1d9..aea776b72 100644 --- a/resources/qml/Connection.qml +++ b/resources/qml/Connection.qml @@ -16,6 +16,9 @@ Item { property bool selected: graph.isConnectionSelected(sourceNodeId, sourcePortIndex, destNodeId, destPortIndex) property bool hovered: false + property string portTypeId: graph.getPortTypeId(sourceNodeId, 1, sourcePortIndex) + property color lineColor: graph.getPortColor(portTypeId) + Connections { target: graph function onNodeRegistryChanged() { @@ -161,7 +164,7 @@ Item { ShapePath { strokeWidth: root.hovered ? 3.5 : 3 - strokeColor: root.hovered ? "#ffffff" : "#eeeeee" + strokeColor: root.hovered ? Qt.lighter(root.lineColor, 1.3) : root.lineColor fillColor: "transparent" startX: root.startPos.x diff --git a/resources/qml/Node.qml b/resources/qml/Node.qml index f27e3c6c4..03e364c0d 100644 --- a/resources/qml/Node.qml +++ b/resources/qml/Node.qml @@ -154,10 +154,18 @@ Rectangle { id: inRepeater model: inPorts delegate: Rectangle { + id: inPortRect width: 12; height: 12 radius: 6 - color: "green" - border.color: "black" + property string portTypeId: graph.getPortTypeId(root.nodeId, 0, index) + property bool isCompatible: !graph.isDragging || + (graph.activeConnectionStart && graph.activeConnectionStart.portType === 1 && + graph.draftConnectionTypeId === portTypeId) + property bool isDimmed: graph.isDragging && !isCompatible + color: graph.getPortColor(portTypeId) + opacity: isDimmed ? 0.3 : 1.0 + border.color: isCompatible && graph.isDragging ? "#ffffff" : "black" + border.width: isCompatible && graph.isDragging ? 2 : 1 MouseArea { anchors.fill: parent @@ -226,10 +234,18 @@ Rectangle { id: outRepeater model: outPorts delegate: Rectangle { + id: outPortRect width: 12; height: 12 radius: 6 - color: "orange" - border.color: "black" + property string portTypeId: graph.getPortTypeId(root.nodeId, 1, index) + property bool isCompatible: !graph.isDragging || + (graph.activeConnectionStart && graph.activeConnectionStart.portType === 0 && + graph.draftConnectionTypeId === portTypeId) + property bool isDimmed: graph.isDragging && !isCompatible + color: graph.getPortColor(portTypeId) + opacity: isDimmed ? 0.3 : 1.0 + border.color: isCompatible && graph.isDragging ? "#ffffff" : "black" + border.width: isCompatible && graph.isDragging ? 2 : 1 MouseArea { anchors.fill: parent diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index c7b4c6af8..39209ad77 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -10,6 +10,24 @@ Item { property var nodeItems: ({}) property Component nodeContentDelegate // User provided content + + // Port type colors mapping + property var portTypeColors: ({ + "decimal": "#4CAF50", + "integer": "#2196F3", + "string": "#FF9800", + "boolean": "#9C27B0", + "default": "#9E9E9E" + }) + + function getPortColor(typeId) { + return portTypeColors[typeId] || portTypeColors["default"] + } + + function getPortTypeId(nodeId, portType, portIndex) { + if (!graphModel) return "default" + return graphModel.getPortDataTypeId(nodeId, portType, portIndex) || "default" + } function registerNode(id, item) { nodeItems[id] = item @@ -236,8 +254,11 @@ Item { dragCurrent = pos isDragging = true activeConnectionStart = {nodeId: nodeId, portType: portType, portIndex: portIndex} + draftConnectionTypeId = getPortTypeId(nodeId, portType, portIndex) } + property string draftConnectionTypeId: "" + property var activeConnectionStart: null function updateDraftConnection(pos) { @@ -271,6 +292,7 @@ Item { function endDraftConnection() { isDragging = false + draftConnectionTypeId = "" if (activePort && activeConnectionStart) { // Check if connecting Out -> In or In -> Out var start = activeConnectionStart diff --git a/src/qml/QuickGraphModel.cpp b/src/qml/QuickGraphModel.cpp index ba8b21d62..06c293826 100644 --- a/src/qml/QuickGraphModel.cpp +++ b/src/qml/QuickGraphModel.cpp @@ -2,6 +2,7 @@ #include "QtNodes/internal/DataFlowGraphModel.hpp" #include "QtNodes/internal/NodeDelegateModelRegistry.hpp" +#include "QtNodes/internal/NodeData.hpp" namespace QtNodes { @@ -109,4 +110,31 @@ QVariantMap QuickGraphModel::getConnectionAtInput(int nodeId, int portIndex) return result; } +QString QuickGraphModel::getPortDataTypeId(int nodeId, int portType, int portIndex) +{ + if (!_model) return QString(); + + auto dataType = _model->portData( + static_cast(nodeId), + static_cast(portType), + static_cast(portIndex), + PortRole::DataType + ).value(); + + return dataType.id; +} + +bool QuickGraphModel::connectionPossible(int outNodeId, int outPortIndex, int inNodeId, int inPortIndex) +{ + if (!_model) return false; + + ConnectionId connId; + connId.outNodeId = static_cast(outNodeId); + connId.outPortIndex = static_cast(outPortIndex); + connId.inNodeId = static_cast(inNodeId); + connId.inPortIndex = static_cast(inPortIndex); + + return _model->connectionPossible(connId); +} + } // namespace QtNodes From 6db3053eedbf2a035e406a1d59419cd402cd5fc6 Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Tue, 2 Dec 2025 23:24:01 -0300 Subject: [PATCH 27/33] fix connect type --- resources/qml/NodeGraph.qml | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index 39209ad77..cb8cb0d8b 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -294,18 +294,30 @@ Item { isDragging = false draftConnectionTypeId = "" if (activePort && activeConnectionStart) { - // Check if connecting Out -> In or In -> Out var start = activeConnectionStart var end = activePort - // We only allow Out -> In connection creation in this simple logic - // If drag started from Out (1) and ended at In (0) + var outNodeId, outPortIndex, inNodeId, inPortIndex + + // Determine Out -> In direction if (start.portType === 1 && end.portType === 0) { - graphModel.addConnection(start.nodeId, start.portIndex, end.nodeId, end.portIndex) + outNodeId = start.nodeId + outPortIndex = start.portIndex + inNodeId = end.nodeId + inPortIndex = end.portIndex + } else if (start.portType === 0 && end.portType === 1) { + outNodeId = end.nodeId + outPortIndex = end.portIndex + inNodeId = start.nodeId + inPortIndex = start.portIndex + } else { + activeConnectionStart = null + return } - // If drag started from In (0) and ended at Out (1) - usually we drag from source to dest - else if (start.portType === 0 && end.portType === 1) { - graphModel.addConnection(end.nodeId, end.portIndex, start.nodeId, start.portIndex) + + // Only create connection if types are compatible + if (graphModel.connectionPossible(outNodeId, outPortIndex, inNodeId, inPortIndex)) { + graphModel.addConnection(outNodeId, outPortIndex, inNodeId, inPortIndex) } } activeConnectionStart = null From c7065a9649555f2e96a6f948fbc73bc40f2b3d3e Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Tue, 2 Dec 2025 23:32:44 -0300 Subject: [PATCH 28/33] feat: theming --- examples/qml_calculator/main.cpp | 1 + examples/qml_calculator/main.qml | 77 +++++++++++++++++++++++++------- resources/qml.qrc | 1 + resources/qml/Connection.qml | 7 +-- resources/qml/Node.qml | 66 ++++++++++++++------------- resources/qml/NodeGraph.qml | 38 ++++++++-------- resources/qml/NodeGraphStyle.qml | 65 +++++++++++++++++++++++++++ 7 files changed, 184 insertions(+), 71 deletions(-) create mode 100644 resources/qml/NodeGraphStyle.qml diff --git a/examples/qml_calculator/main.cpp b/examples/qml_calculator/main.cpp index c6c676ec9..85ab96c5d 100644 --- a/examples/qml_calculator/main.cpp +++ b/examples/qml_calculator/main.cpp @@ -60,6 +60,7 @@ int main(int argc, char *argv[]) qmlRegisterType(QUrl("qrc:/QtNodes/QML/qml/NodeGraph.qml"), "QtNodes", 1, 0, "NodeGraph"); qmlRegisterType(QUrl("qrc:/QtNodes/QML/qml/Node.qml"), "QtNodes", 1, 0, "Node"); qmlRegisterType(QUrl("qrc:/QtNodes/QML/qml/Connection.qml"), "QtNodes", 1, 0, "Connection"); + qmlRegisterType(QUrl("qrc:/QtNodes/QML/qml/NodeGraphStyle.qml"), "QtNodes", 1, 0, "NodeGraphStyle"); auto registry = registerDataModels(); auto graphModel = new QuickGraphModel(); diff --git a/examples/qml_calculator/main.qml b/examples/qml_calculator/main.qml index d8e50c3bd..8fcc7ff0c 100644 --- a/examples/qml_calculator/main.qml +++ b/examples/qml_calculator/main.qml @@ -10,7 +10,40 @@ Window { height: 800 title: "QML Calculator - Extended" - property QuickGraphModel model: _graphModel + property QuickGraphModel model: _graphModel + property bool darkTheme: true + + // Custom dark theme + NodeGraphStyle { + id: darkStyle + canvasBackground: "#1e1e1e" + gridMinorLine: "#2a2a2a" + gridMajorLine: "#0f0f0f" + nodeBackground: "#2d2d2d" + nodeBorder: "#1a1a1a" + nodeSelectedBorder: "#4a9eff" + nodeCaptionColor: "#eeeeee" + nodeContentColor: "#ffffff" + connectionSelectionOutline: "#4a9eff" + selectionRectFill: "#224a9eff" + selectionRectBorder: "#4a9eff" + } + + // Custom light theme + NodeGraphStyle { + id: lightStyle + canvasBackground: "#f5f5f5" + gridMinorLine: "#e0e0e0" + gridMajorLine: "#c0c0c0" + nodeBackground: "#ffffff" + nodeBorder: "#cccccc" + nodeSelectedBorder: "#2196F3" + nodeCaptionColor: "#333333" + nodeContentColor: "#333333" + connectionSelectionOutline: "#2196F3" + selectionRectFill: "#222196F3" + selectionRectBorder: "#2196F3" + } Column { anchors.fill: parent @@ -19,16 +52,23 @@ Window { Rectangle { width: parent.width height: 50 - color: "#3c3c3c" + color: darkTheme ? "#3c3c3c" : "#e0e0e0" RowLayout { anchors.fill: parent anchors.margins: 5 spacing: 10 + Button { + text: darkTheme ? "☀ Light" : "🌙 Dark" + onClicked: darkTheme = !darkTheme + } + + Rectangle { width: 1; height: 30; color: "#555" } + Label { text: "Numbers:" - color: "#aaa" + color: darkTheme ? "#aaa" : "#555" font.bold: true } Button { @@ -37,11 +77,11 @@ Window { palette.buttonText: "#4CAF50" } - Rectangle { width: 1; height: 30; color: "#555" } + Rectangle { width: 1; height: 30; color: darkTheme ? "#555" : "#bbb" } Label { text: "Math:" - color: "#aaa" + color: darkTheme ? "#aaa" : "#555" font.bold: true } Button { text: "Add"; onClicked: model.addNode("Addition") } @@ -49,11 +89,11 @@ Window { Button { text: "Multiply"; onClicked: model.addNode("Multiply") } Button { text: "Divide"; onClicked: model.addNode("Divide") } - Rectangle { width: 1; height: 30; color: "#555" } + Rectangle { width: 1; height: 30; color: darkTheme ? "#555" : "#bbb" } Label { text: "String:" - color: "#aaa" + color: darkTheme ? "#aaa" : "#555" font.bold: true } Button { @@ -67,11 +107,11 @@ Window { palette.buttonText: "#FF9800" } - Rectangle { width: 1; height: 30; color: "#555" } + Rectangle { width: 1; height: 30; color: darkTheme ? "#555" : "#bbb" } Label { text: "Integer:" - color: "#aaa" + color: darkTheme ? "#aaa" : "#555" font.bold: true } Button { @@ -85,11 +125,11 @@ Window { palette.buttonText: "#2196F3" } - Rectangle { width: 1; height: 30; color: "#555" } + Rectangle { width: 1; height: 30; color: darkTheme ? "#555" : "#bbb" } Label { text: "Logic:" - color: "#aaa" + color: darkTheme ? "#aaa" : "#555" font.bold: true } Button { @@ -98,11 +138,11 @@ Window { palette.buttonText: "#9C27B0" } - Rectangle { width: 1; height: 30; color: "#555" } + Rectangle { width: 1; height: 30; color: darkTheme ? "#555" : "#bbb" } Label { text: "Display:" - color: "#aaa" + color: darkTheme ? "#aaa" : "#555" font.bold: true } Button { @@ -126,9 +166,11 @@ Window { } NodeGraph { + id: nodeGraph width: parent.width height: parent.height - 50 graphModel: model + style: darkTheme ? darkStyle : lightStyle Component.onCompleted: { // Create a demo graph: (5 + 3) * 2 = 16, formatted as text @@ -172,6 +214,7 @@ Window { Item { property var delegateModel property string nodeType + property var contentColor: nodeGraph.style.nodeContentColor // NumberSource - editable number input TextField { @@ -205,7 +248,7 @@ Window { anchors.centerIn: parent visible: nodeType === "Addition" text: "+" - color: "white" + color: contentColor font.pixelSize: 36 font.bold: true } @@ -214,7 +257,7 @@ Window { anchors.centerIn: parent visible: nodeType === "Subtract" text: "−" - color: "white" + color: contentColor font.pixelSize: 36 font.bold: true } @@ -223,7 +266,7 @@ Window { anchors.centerIn: parent visible: nodeType === "Multiply" text: "×" - color: "white" + color: contentColor font.pixelSize: 36 font.bold: true } @@ -232,7 +275,7 @@ Window { anchors.centerIn: parent visible: nodeType === "Divide" text: "÷" - color: "white" + color: contentColor font.pixelSize: 36 font.bold: true } diff --git a/resources/qml.qrc b/resources/qml.qrc index 81c2fdfbe..c3c8d4b46 100644 --- a/resources/qml.qrc +++ b/resources/qml.qrc @@ -3,5 +3,6 @@ qml/NodeGraph.qml qml/Node.qml qml/Connection.qml + qml/NodeGraphStyle.qml diff --git a/resources/qml/Connection.qml b/resources/qml/Connection.qml index aea776b72..217b86ef0 100644 --- a/resources/qml/Connection.qml +++ b/resources/qml/Connection.qml @@ -4,6 +4,7 @@ import QtQuick.Shapes 1.15 Item { id: root property var graph + property var style: graph ? graph.style : null property int sourceNodeId: -1 property int sourcePortIndex: -1 @@ -140,8 +141,8 @@ Item { visible: root.selected ShapePath { - strokeWidth: 7 - strokeColor: "#4a9eff" + strokeWidth: style.connectionSelectionOutlineWidth + strokeColor: style.connectionSelectionOutline fillColor: "transparent" startX: root.startPos.x @@ -163,7 +164,7 @@ Item { anchors.fill: parent ShapePath { - strokeWidth: root.hovered ? 3.5 : 3 + strokeWidth: root.hovered ? style.connectionHoverWidth : style.connectionWidth strokeColor: root.hovered ? Qt.lighter(root.lineColor, 1.3) : root.lineColor fillColor: "transparent" diff --git a/resources/qml/Node.qml b/resources/qml/Node.qml index 03e364c0d..ba10a541f 100644 --- a/resources/qml/Node.qml +++ b/resources/qml/Node.qml @@ -20,16 +20,19 @@ Rectangle { return graph.isNodeSelected(nodeId) } + // Style shortcut - use direct access for reactivity + property var style: graph ? graph.style : null + x: initialX y: initialY - width: 150 - height: Math.max(Math.max(inPorts, outPorts) * 20 + 40, 50) + width: style.nodeMinWidth + height: Math.max(Math.max(inPorts, outPorts) * (style.portSize + style.nodePortSpacing) + style.nodeHeaderHeight + 5, 50) - color: "#2d2d2d" - border.color: selected ? "#4a9eff" : "black" - border.width: selected ? 3 : 2 - radius: 5 + color: style.nodeBackground + border.color: selected ? style.nodeSelectedBorder : style.nodeBorder + border.width: selected ? style.nodeSelectedBorderWidth : style.nodeBorderWidth + radius: style.nodeRadius Component.onCompleted: { completed = true @@ -108,17 +111,18 @@ Rectangle { anchors.top: parent.top anchors.topMargin: 8 text: caption - color: "#eeeeee" - font.bold: true + color: graph && graph.style ? graph.style.nodeCaptionColor : "#eeeeee" + font.bold: graph && graph.style ? graph.style.nodeCaptionBold : true + font.pixelSize: graph && graph.style ? graph.style.nodeCaptionFontSize : 12 } Loader { id: contentLoader anchors.top: parent.top - anchors.topMargin: 35 + anchors.topMargin: style.nodeHeaderHeight anchors.horizontalCenter: parent.horizontalCenter width: parent.width - 20 - height: parent.height - 50 + height: parent.height - style.nodeHeaderHeight - 15 sourceComponent: contentDelegate onLoaded: { @@ -146,35 +150,34 @@ Rectangle { z: 10 anchors.left: parent.left anchors.top: parent.top - anchors.topMargin: 35 - anchors.leftMargin: -5 // Overlap edge - spacing: 10 + anchors.topMargin: style.nodeHeaderHeight + anchors.leftMargin: -style.portSize / 2 + 1 + spacing: style.nodePortSpacing Repeater { id: inRepeater model: inPorts delegate: Rectangle { id: inPortRect - width: 12; height: 12 - radius: 6 + width: style.portSize; height: style.portSize + radius: style.portSize / 2 property string portTypeId: graph.getPortTypeId(root.nodeId, 0, index) property bool isCompatible: !graph.isDragging || (graph.activeConnectionStart && graph.activeConnectionStart.portType === 1 && graph.draftConnectionTypeId === portTypeId) property bool isDimmed: graph.isDragging && !isCompatible color: graph.getPortColor(portTypeId) - opacity: isDimmed ? 0.3 : 1.0 - border.color: isCompatible && graph.isDragging ? "#ffffff" : "black" - border.width: isCompatible && graph.isDragging ? 2 : 1 + opacity: isDimmed ? style.portDimmedOpacity : 1.0 + border.color: isCompatible && graph.isDragging ? style.portHighlightBorder : style.portBorderColor + border.width: isCompatible && graph.isDragging ? style.portHighlightBorderWidth : style.portBorderWidth MouseArea { anchors.fill: parent hoverEnabled: true preventStealing: true onEntered: { - // Only highlight if not dragging or if we are the target if (!graph.isDragging) { - parent.scale = 1.2 + parent.scale = style.portHoverScale graph.setActivePort({nodeId: root.nodeId, portType: 0, portIndex: index}) } } @@ -185,12 +188,11 @@ Rectangle { } } - // Visual feedback based on activePort property bool isActive: { var ap = graph.activePort return ap && ap.nodeId === root.nodeId && ap.portType === 0 && ap.portIndex === index } - onIsActiveChanged: parent.scale = isActive ? 1.4 : 1.0 + onIsActiveChanged: parent.scale = isActive ? style.portActiveScale : 1.0 onPressed: (mouse) => { var existing = graph.graphModel.getConnectionAtInput(root.nodeId, index) var mousePos = mapToItem(graph.canvas, mouse.x, mouse.y) @@ -226,26 +228,26 @@ Rectangle { z: 10 anchors.right: parent.right anchors.top: parent.top - anchors.topMargin: 35 - anchors.rightMargin: -5 - spacing: 10 + anchors.topMargin: style.nodeHeaderHeight + anchors.rightMargin: -style.portSize / 2 + 1 + spacing: style.nodePortSpacing Repeater { id: outRepeater model: outPorts delegate: Rectangle { id: outPortRect - width: 12; height: 12 - radius: 6 + width: style.portSize; height: style.portSize + radius: style.portSize / 2 property string portTypeId: graph.getPortTypeId(root.nodeId, 1, index) property bool isCompatible: !graph.isDragging || (graph.activeConnectionStart && graph.activeConnectionStart.portType === 0 && graph.draftConnectionTypeId === portTypeId) property bool isDimmed: graph.isDragging && !isCompatible color: graph.getPortColor(portTypeId) - opacity: isDimmed ? 0.3 : 1.0 - border.color: isCompatible && graph.isDragging ? "#ffffff" : "black" - border.width: isCompatible && graph.isDragging ? 2 : 1 + opacity: isDimmed ? style.portDimmedOpacity : 1.0 + border.color: isCompatible && graph.isDragging ? style.portHighlightBorder : style.portBorderColor + border.width: isCompatible && graph.isDragging ? style.portHighlightBorderWidth : style.portBorderWidth MouseArea { anchors.fill: parent @@ -253,7 +255,7 @@ Rectangle { preventStealing: true onEntered: { if (!graph.isDragging) { - parent.scale = 1.2 + parent.scale = style.portHoverScale graph.setActivePort({nodeId: root.nodeId, portType: 1, portIndex: index}) } } @@ -268,7 +270,7 @@ Rectangle { var ap = graph.activePort return ap && ap.nodeId === root.nodeId && ap.portType === 1 && ap.portIndex === index } - onIsActiveChanged: parent.scale = isActive ? 1.4 : 1.0 + onIsActiveChanged: parent.scale = isActive ? style.portActiveScale : 1.0 onPressed: (mouse) => { var pos = mapToItem(graph.canvas, width/2, height/2) diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index cb8cb0d8b..561b31321 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -11,17 +11,11 @@ Item { property var nodeItems: ({}) property Component nodeContentDelegate // User provided content - // Port type colors mapping - property var portTypeColors: ({ - "decimal": "#4CAF50", - "integer": "#2196F3", - "string": "#FF9800", - "boolean": "#9C27B0", - "default": "#9E9E9E" - }) + // Style - can be overridden by user + property NodeGraphStyle style: NodeGraphStyle {} function getPortColor(typeId) { - return portTypeColors[typeId] || portTypeColors["default"] + return style.getPortColor(typeId) } function getPortTypeId(nodeId, portType, portIndex) { @@ -329,7 +323,7 @@ Item { Rectangle { anchors.fill: parent - color: "#2b2b2b" + color: style.canvasBackground clip: true // Input Handler for Pan/Zoom/Selection @@ -440,9 +434,15 @@ Item { anchors.fill: parent property real zoom: root.zoomLevel property point offset: root.panOffset + property color minorColor: style.gridMinorLine + property color majorColor: style.gridMajorLine + property real minorSpacing: style.gridMinorSpacing + property real majorSpacing: style.gridMajorSpacing onZoomChanged: requestPaint() onOffsetChanged: requestPaint() + onMinorColorChanged: requestPaint() + onMajorColorChanged: requestPaint() onPaint: { var ctx = getContext("2d") @@ -450,8 +450,8 @@ Item { ctx.lineWidth = 1 - var gridSize = 20 * zoom - var majorGridSize = 100 * zoom + var gridSize = minorSpacing * zoom + var majorGridSize = majorSpacing * zoom var startX = (offset.x % gridSize) var startY = (offset.y % gridSize) @@ -460,7 +460,7 @@ Item { if (startY < 0) startY += gridSize // Minor lines - ctx.strokeStyle = "#353535" + ctx.strokeStyle = minorColor ctx.beginPath() // Vertical lines @@ -478,7 +478,7 @@ Item { ctx.stroke() // Major lines - ctx.strokeStyle = "#151515" + ctx.strokeStyle = majorColor ctx.beginPath() var mStartX = (offset.x % majorGridSize) var mStartY = (offset.y % majorGridSize) @@ -555,8 +555,8 @@ Item { Shape { visible: root.isDragging ShapePath { - strokeWidth: 2 - strokeColor: "orange" + strokeWidth: style.draftConnectionWidth + strokeColor: style.draftConnectionColor fillColor: "transparent" startX: root.dragStart.x startY: root.dragStart.y @@ -578,9 +578,9 @@ Item { y: Math.min(root.marqueeStart.y, root.marqueeEnd.y) width: Math.abs(root.marqueeEnd.x - root.marqueeStart.x) height: Math.abs(root.marqueeEnd.y - root.marqueeStart.y) - color: "#224a9eff" - border.color: "#4a9eff" - border.width: 1 + color: style.selectionRectFill + border.color: style.selectionRectBorder + border.width: style.selectionRectBorderWidth } } } diff --git a/resources/qml/NodeGraphStyle.qml b/resources/qml/NodeGraphStyle.qml new file mode 100644 index 000000000..44a0fe1fb --- /dev/null +++ b/resources/qml/NodeGraphStyle.qml @@ -0,0 +1,65 @@ +import QtQuick 2.15 + +QtObject { + // Canvas + property color canvasBackground: "#2b2b2b" + property color gridMinorLine: "#353535" + property color gridMajorLine: "#151515" + property real gridMinorSpacing: 20 + property real gridMajorSpacing: 100 + + // Node + property color nodeBackground: "#2d2d2d" + property color nodeBorder: "black" + property color nodeSelectedBorder: "#4a9eff" + property real nodeBorderWidth: 2 + property real nodeSelectedBorderWidth: 3 + property real nodeRadius: 5 + property color nodeCaptionColor: "#eeeeee" + property int nodeCaptionFontSize: 12 + property bool nodeCaptionBold: true + property real nodeMinWidth: 150 + property real nodePortSpacing: 10 + property real nodeHeaderHeight: 35 + property color nodeContentColor: "#ffffff" + + // Ports + property real portSize: 12 + property real portBorderWidth: 1 + property color portBorderColor: "black" + property color portHighlightBorder: "#ffffff" + property real portHighlightBorderWidth: 2 + property real portHoverScale: 1.2 + property real portActiveScale: 1.4 + property real portDimmedOpacity: 0.3 + + // Port type colors + property var portTypeColors: ({ + "decimal": "#4CAF50", + "integer": "#2196F3", + "string": "#FF9800", + "boolean": "#9C27B0", + "default": "#9E9E9E" + }) + + // Connection + property real connectionWidth: 3 + property real connectionHoverWidth: 3.5 + property real connectionSelectedWidth: 4 + property color connectionSelectionOutline: "#4a9eff" + property real connectionSelectionOutlineWidth: 7 + + // Draft connection + property real draftConnectionWidth: 2 + property color draftConnectionColor: "orange" + + // Selection + property color selectionRectFill: "#224a9eff" + property color selectionRectBorder: "#4a9eff" + property real selectionRectBorderWidth: 1 + + // Helper function + function getPortColor(typeId) { + return portTypeColors[typeId] || portTypeColors["default"] + } +} From 80a9077ed3022d7274497238a6718db27fc51901 Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Tue, 2 Dec 2025 23:52:39 -0300 Subject: [PATCH 29/33] feat: undo redo + readme qml updated --- README_QML.md | 144 +++++++++++++++---- examples/qml_calculator/main.qml | 15 +- include/QtNodes/qml/QuickGraphModel.hpp | 14 ++ resources/qml/NodeGraph.qml | 10 ++ src/qml/QuickGraphModel.cpp | 182 +++++++++++++++++++++++- 5 files changed, 333 insertions(+), 32 deletions(-) diff --git a/README_QML.md b/README_QML.md index 920035860..455415acc 100644 --- a/README_QML.md +++ b/README_QML.md @@ -7,39 +7,95 @@ This document describes the implementation of QML support for the **QtNodes** li The implementation follows a Model-View-ViewModel (MVVM) pattern adapted for Qt/QML: ### 1. C++ Integration Layer (`src/qml/`) -* **`QuickGraphModel`**: The main controller class. It wraps the internal `DataFlowGraphModel` and exposes high-level operations (add/remove nodes, create connections) to QML. +* **`QuickGraphModel`**: The main controller class. It wraps the internal `DataFlowGraphModel` and exposes high-level operations (add/remove nodes, create connections) to QML. It also manages an **UndoStack** for undo/redo operations. * **`NodesListModel`**: A `QAbstractListModel` that exposes the nodes in the graph. It provides roles for properties like position, caption, and input/output port counts. Crucially, it exposes the underlying `NodeDelegateModel` as a `QObject*`, allowing QML to bind directly to custom node data (e.g., numbers, text). * **`ConnectionsListModel`**: A `QAbstractListModel` that tracks active connections, providing source/destination node IDs and port indices. ### 2. QML Components (`resources/qml/`) * **`NodeGraph.qml`**: The main canvas component. - * Handles **Infinite Panning & Zooming** using a background `MouseArea` and transform/scale logic. + * Handles **Infinite Panning & Zooming** (mouse-centered) using a background `MouseArea` and transform/scale logic. * Renders a dynamic **Infinite Grid** using a `Canvas` item (avoiding shader compatibility issues). * Manages the lifecycle of Nodes and Connections using `Repeater`s linked to the C++ models. * Handles **Connection Drafting**: Implements geometry-based hit-testing to reliably find target nodes/ports under the mouse cursor, ignoring z-order overlays. + * Supports **Marquee Selection** for selecting multiple nodes and connections. + * Handles **Keyboard Shortcuts**: Delete/Backspace/X for deletion, Ctrl+Z for undo, Ctrl+Shift+Z/Ctrl+Y for redo. * **`Node.qml`**: A generic node shell. * Displays the node caption and background. - * Generates input/output ports dynamically. + * Generates input/output ports dynamically with **type-based coloring**. * Uses a `Loader` with a `nodeContentDelegate` to allow users to inject **custom QML content** inside the node (e.g., text fields, images) with full property binding propagation. - * Handles node dragging and position updates, with feedback loops prevented by threshold checks. + * Handles node dragging and position updates, including **group dragging** for selected nodes. + * Shows visual feedback for selected state. * **`Connection.qml`**: * Renders connections as smooth cubic Bezier curves using `QtQuick.Shapes`. - * Updates geometry in real-time when linked nodes are moved by monitoring specific `xChanged`/`yChanged` signals. + * Updates geometry in real-time when linked nodes are moved. + * Supports **selection** (click or Ctrl+click) and **hover highlighting**. + * Uses **port type colors** for visual consistency. +* **`NodeGraphStyle.qml`**: A centralized styling component for theming. + * Defines colors, sizes, and appearance for canvas, nodes, ports, connections, and selection. + * Supports **custom themes** (e.g., dark/light mode) by instantiating with different property values. + * Includes port type color mapping for type safety visualization. ## Features Implemented +### Core Functionality * ✅ **Hybrid C++/QML Architecture**: Full separation of graph logic (C++) and UI (QML). * ✅ **Dynamic Graph Rendering**: Nodes and connections appear and update automatically based on the C++ model. -* ✅ **Interactive Workspace**: Smooth zooming and panning of the graph canvas. +* ✅ **Interactive Workspace**: Smooth zooming (mouse-centered) and panning of the graph canvas. * ✅ **Node Manipulation**: Drag-and-drop nodes to move them. * ✅ **Connection Creation**: Drag from any port to a compatible target port to create a connection. -* ✅ **Customizable Nodes**: Users can define the look and behavior of specific node types (e.g., "NumberSource") completely in QML. -* ✅ **Example Application**: `qml_calculator` demonstrates a working calculator where C++ handles the math and QML handles the UI. +* ✅ **Customizable Nodes**: Users can define the look and behavior of specific node types completely in QML. + +### Selection & Editing +* ✅ **Node Selection**: Click to select, Ctrl+click for additive selection. +* ✅ **Marquee Selection**: Click and drag on canvas to select multiple nodes and connections. +* ✅ **Group Dragging**: Drag any selected node to move all selected nodes together. +* ✅ **Connection Selection**: Click on connections to select them, with hover highlighting. +* ✅ **Node Deletion**: Delete selected nodes via Delete/Backspace/X keys. +* ✅ **Connection Deletion**: Delete selected connections via Delete/Backspace/X keys. +* ✅ **Disconnect by Dragging**: Drag from an input port to disconnect and re-route an existing connection. + +### Type Safety & Visual Feedback +* ✅ **Port Type Colors**: Ports are colored based on their data type (decimal=green, integer=blue, string=orange, boolean=purple). +* ✅ **Compatibility Highlighting**: During connection dragging, compatible ports are highlighted while incompatible ports are dimmed. +* ✅ **Connection Type Colors**: Connections inherit the color of their source port type. + +### Theming & Styling +* ✅ **NodeGraphStyle.qml**: Centralized styling with customizable properties for: + * Canvas background and grid colors + * Node background, border, caption, and selection colors + * Port sizes, colors, and hover/active states + * Connection width, hover effects, and selection outline + * Marquee selection appearance +* ✅ **Theme Switching**: Support for runtime theme changes (e.g., dark/light mode toggle). +* ✅ **Reactive Styling**: All components respond to style property changes in real-time. + +### Undo/Redo +* ✅ **Full Undo/Redo Support**: All graph operations are undoable: + * Add/Remove nodes + * Add/Remove connections +* ✅ **Keyboard Shortcuts**: Ctrl+Z (undo), Ctrl+Shift+Z or Ctrl+Y (redo). +* ✅ **QML API**: `canUndo`/`canRedo` properties and `undo()`/`redo()` methods exposed to QML. + +### Focus Management +* ✅ **Correct Input Focus**: Text fields inside nodes properly receive and release focus. +* ✅ **Canvas Focus**: Clicking on canvas or nodes removes focus from inputs for keyboard shortcuts to work. + +## Example Application + +The `qml_calculator` example demonstrates all features: +* Multiple node types: NumberSource, Addition, Subtract, Multiply, Divide, FormatNumber, StringDisplay, IntegerSource, ToInteger, GreaterThan, NumberDisplay, IntegerDisplay, BooleanDisplay +* **Theme Toggle Button**: Switch between dark and light themes at runtime +* **Undo/Redo Buttons**: Visual buttons in toolbar with enabled/disabled state +* **Custom Node Content**: Each node type has its own QML UI (text fields, labels, symbols) +* **Type-Safe Connections**: Connections enforce type compatibility with visual feedback ## Technical Notes + * **Grid Implementation**: The grid is drawn using an HTML5-style `Canvas` API rather than GLSL shaders. This ensures compatibility with Qt 6's RHI (which removed inline OpenGL shaders) while maintaining performance for infinite grid rendering. * **Z-Ordering & Hit Testing**: Custom geometry-based hit testing is used for connection drafting because the temporary connection line (a `Shape` item) overlays the nodes, blocking standard `childAt` calls. * **Coordinate Mapping**: All drag operations use `mapToItem`/`mapFromItem` relative to the main `canvas` item to ensure correct positioning regardless of the current pan/zoom state. +* **Reactive Bindings**: Style properties use direct `graph.style` access for proper reactivity when themes change. +* **Undo Commands**: Custom `QUndoCommand` subclasses handle node state serialization for proper undo/redo of node additions and deletions. ## How to Build @@ -57,22 +113,56 @@ make ./bin/qml_calculator ``` -## Next Steps (Roadmap) - -To achieve full feature parity with the Widgets-based version, the following features need to be implemented: - -1. **Connection Interaction**: - * Ability to select/highlight existing connections. - * Ability to delete connections (e.g., via right-click menu or keyboard shortcut). -2. **Node Deletion**: - * UI mechanism to delete selected nodes. -3. **Selection Model**: - * Support for selecting multiple nodes (marquee selection). - * Visual feedback for selected states. -4. **Undo/Redo Stack**: - * Expose the C++ `UndoStack` to QML to trigger undo/redo actions. -5. **Styling**: - * Expose more style properties (colors, line thickness) to QML for easy theming. -6. **Port Data & Type Safety**: - * Visualize port data types (colors based on type). - * Add visual feedback during connection dragging (highlight compatible ports, dim incompatible ones). +## API Reference + +### QuickGraphModel (C++ → QML) + +| Property/Method | Type | Description | +|-----------------|------|-------------| +| `nodes` | NodesListModel* | List model of all nodes | +| `connections` | ConnectionsListModel* | List model of all connections | +| `canUndo` | bool | Whether undo is available | +| `canRedo` | bool | Whether redo is available | +| `addNode(nodeType)` | int | Add a node, returns node ID | +| `removeNode(nodeId)` | bool | Remove a node | +| `addConnection(...)` | void | Create a connection | +| `removeConnection(...)` | void | Remove a connection | +| `undo()` | void | Undo last operation | +| `redo()` | void | Redo last undone operation | + +### NodeGraph.qml + +| Property | Type | Description | +|----------|------|-------------| +| `graphModel` | QuickGraphModel | The C++ model to visualize | +| `style` | NodeGraphStyle | Styling configuration | +| `nodeContentDelegate` | Component | Custom content for nodes | + +### NodeGraphStyle.qml + +| Category | Properties | +|----------|------------| +| Canvas | `canvasBackground`, `gridMinorLine`, `gridMajorLine`, `gridMinorSpacing`, `gridMajorSpacing` | +| Node | `nodeBackground`, `nodeBorder`, `nodeSelectedBorder`, `nodeBorderWidth`, `nodeSelectedBorderWidth`, `nodeRadius`, `nodeCaptionColor`, `nodeCaptionFontSize`, `nodeCaptionBold`, `nodeMinWidth`, `nodePortSpacing`, `nodeHeaderHeight`, `nodeContentColor` | +| Ports | `portSize`, `portBorderWidth`, `portBorderColor`, `portHighlightBorder`, `portHighlightBorderWidth`, `portHoverScale`, `portActiveScale`, `portDimmedOpacity`, `portTypeColors` | +| Connection | `connectionWidth`, `connectionHoverWidth`, `connectionSelectedWidth`, `connectionSelectionOutline`, `connectionSelectionOutlineWidth`, `draftConnectionWidth`, `draftConnectionColor` | +| Selection | `selectionRectFill`, `selectionRectBorder`, `selectionRectBorderWidth` | + +## Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| Delete / Backspace / X | Delete selected nodes and connections | +| Ctrl+Z | Undo | +| Ctrl+Shift+Z / Ctrl+Y | Redo | +| Ctrl+Click | Additive selection | + +## Future Enhancements + +The QML implementation now has feature parity with the Widgets version. Potential future enhancements: + +* **Copy/Paste**: Clipboard support for nodes and connections +* **Node Groups**: Collapsible node groups for complex graphs +* **Minimap**: Overview navigation for large graphs +* **Animation**: Smooth transitions for node/connection state changes +* **Touch Support**: Multi-touch gestures for mobile/tablet devices diff --git a/examples/qml_calculator/main.qml b/examples/qml_calculator/main.qml index 8fcc7ff0c..057407b95 100644 --- a/examples/qml_calculator/main.qml +++ b/examples/qml_calculator/main.qml @@ -64,7 +64,20 @@ Window { onClicked: darkTheme = !darkTheme } - Rectangle { width: 1; height: 30; color: "#555" } + Rectangle { width: 1; height: 30; color: darkTheme ? "#555" : "#bbb" } + + Button { + text: "↶ Undo" + enabled: model.canUndo + onClicked: model.undo() + } + Button { + text: "↷ Redo" + enabled: model.canRedo + onClicked: model.redo() + } + + Rectangle { width: 1; height: 30; color: darkTheme ? "#555" : "#bbb" } Label { text: "Numbers:" diff --git a/include/QtNodes/qml/QuickGraphModel.hpp b/include/QtNodes/qml/QuickGraphModel.hpp index 674ddbf04..db98ab42e 100644 --- a/include/QtNodes/qml/QuickGraphModel.hpp +++ b/include/QtNodes/qml/QuickGraphModel.hpp @@ -6,6 +6,8 @@ #include "NodesListModel.hpp" #include "ConnectionsListModel.hpp" +class QUndoStack; + namespace QtNodes { class DataFlowGraphModel; @@ -16,6 +18,8 @@ class QuickGraphModel : public QObject Q_OBJECT Q_PROPERTY(QtNodes::NodesListModel* nodes READ nodes CONSTANT) Q_PROPERTY(QtNodes::ConnectionsListModel* connections READ connections CONSTANT) + Q_PROPERTY(bool canUndo READ canUndo NOTIFY undoStateChanged) + Q_PROPERTY(bool canRedo READ canRedo NOTIFY undoStateChanged) public: explicit QuickGraphModel(QObject *parent = nullptr); @@ -38,11 +42,21 @@ class QuickGraphModel : public QObject Q_INVOKABLE QVariantMap getConnectionAtInput(int nodeId, int portIndex); Q_INVOKABLE QString getPortDataTypeId(int nodeId, int portType, int portIndex); Q_INVOKABLE bool connectionPossible(int outNodeId, int outPortIndex, int inNodeId, int inPortIndex); + + // Undo/Redo + bool canUndo() const; + bool canRedo() const; + Q_INVOKABLE void undo(); + Q_INVOKABLE void redo(); + +Q_SIGNALS: + void undoStateChanged(); private: std::shared_ptr _model; NodesListModel* _nodesList; ConnectionsListModel* _connectionsList; + QUndoStack* _undoStack; }; } // namespace QtNodes diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index 561b31321..2f4fb473a 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -228,6 +228,16 @@ Item { if (event.key === Qt.Key_Delete || event.key === Qt.Key_Backspace || event.key === Qt.Key_X) { deleteSelected() event.accepted = true + } else if (event.key === Qt.Key_Z && (event.modifiers & Qt.ControlModifier)) { + if (event.modifiers & Qt.ShiftModifier) { + if (graphModel) graphModel.redo() + } else { + if (graphModel) graphModel.undo() + } + event.accepted = true + } else if (event.key === Qt.Key_Y && (event.modifiers & Qt.ControlModifier)) { + if (graphModel) graphModel.redo() + event.accepted = true } } diff --git a/src/qml/QuickGraphModel.cpp b/src/qml/QuickGraphModel.cpp index 06c293826..17a5bdbd7 100644 --- a/src/qml/QuickGraphModel.cpp +++ b/src/qml/QuickGraphModel.cpp @@ -4,13 +4,162 @@ #include "QtNodes/internal/NodeDelegateModelRegistry.hpp" #include "QtNodes/internal/NodeData.hpp" +#include +#include +#include + namespace QtNodes { +// Undo command for adding a node +class AddNodeCommand : public QUndoCommand +{ +public: + AddNodeCommand(DataFlowGraphModel* graphModel, + const QString& nodeType, QUndoCommand* parent = nullptr) + : QUndoCommand(parent) + , _graphModel(graphModel) + , _nodeType(nodeType) + , _nodeId(-1) + { + setText(QString("Add %1").arg(nodeType)); + } + + void undo() override + { + if (_nodeId >= 0 && _graphModel) { + _savedState = _graphModel->saveNode(static_cast(_nodeId)); + _graphModel->deleteNode(static_cast(_nodeId)); + } + } + + void redo() override + { + if (_graphModel) { + if (_nodeId < 0) { + _nodeId = static_cast(_graphModel->addNode(_nodeType)); + } else if (!_savedState.isEmpty()) { + _graphModel->loadNode(_savedState); + } + } + } + + int nodeId() const { return _nodeId; } + +private: + DataFlowGraphModel* _graphModel; + QString _nodeType; + int _nodeId; + QJsonObject _savedState; +}; + +// Undo command for removing a node +class RemoveNodeCommand : public QUndoCommand +{ +public: + RemoveNodeCommand(DataFlowGraphModel* graphModel, int nodeId, QUndoCommand* parent = nullptr) + : QUndoCommand(parent) + , _graphModel(graphModel) + , _nodeId(nodeId) + { + setText(QString("Remove Node %1").arg(nodeId)); + if (_graphModel) { + _savedState = _graphModel->saveNode(static_cast(_nodeId)); + } + } + + void undo() override + { + if (_graphModel && !_savedState.isEmpty()) { + _graphModel->loadNode(_savedState); + } + } + + void redo() override + { + if (_graphModel) { + _savedState = _graphModel->saveNode(static_cast(_nodeId)); + _graphModel->deleteNode(static_cast(_nodeId)); + } + } + +private: + DataFlowGraphModel* _graphModel; + int _nodeId; + QJsonObject _savedState; +}; + +// Undo command for adding a connection +class AddConnectionCommand : public QUndoCommand +{ +public: + AddConnectionCommand(DataFlowGraphModel* graphModel, const ConnectionId& connId, + QUndoCommand* parent = nullptr) + : QUndoCommand(parent) + , _graphModel(graphModel) + , _connId(connId) + { + setText("Add Connection"); + } + + void undo() override + { + if (_graphModel) { + _graphModel->deleteConnection(_connId); + } + } + + void redo() override + { + if (_graphModel) { + _graphModel->addConnection(_connId); + } + } + +private: + DataFlowGraphModel* _graphModel; + ConnectionId _connId; +}; + +// Undo command for removing a connection +class RemoveConnectionCommand : public QUndoCommand +{ +public: + RemoveConnectionCommand(DataFlowGraphModel* graphModel, const ConnectionId& connId, + QUndoCommand* parent = nullptr) + : QUndoCommand(parent) + , _graphModel(graphModel) + , _connId(connId) + { + setText("Remove Connection"); + } + + void undo() override + { + if (_graphModel) { + _graphModel->addConnection(_connId); + } + } + + void redo() override + { + if (_graphModel) { + _graphModel->deleteConnection(_connId); + } + } + +private: + DataFlowGraphModel* _graphModel; + ConnectionId _connId; +}; + QuickGraphModel::QuickGraphModel(QObject *parent) : QObject(parent) , _nodesList(nullptr) , _connectionsList(nullptr) + , _undoStack(new QUndoStack(this)) { + connect(_undoStack, &QUndoStack::canUndoChanged, this, &QuickGraphModel::undoStateChanged); + connect(_undoStack, &QUndoStack::canRedoChanged, this, &QuickGraphModel::undoStateChanged); } QuickGraphModel::~QuickGraphModel() @@ -56,13 +205,18 @@ ConnectionsListModel* QuickGraphModel::connections() const int QuickGraphModel::addNode(QString const &nodeType) { if (!_model) return -1; - return static_cast(_model->addNode(nodeType)); + + auto* cmd = new AddNodeCommand(_model.get(), nodeType); + _undoStack->push(cmd); + return cmd->nodeId(); } bool QuickGraphModel::removeNode(int nodeId) { if (!_model) return false; - return _model->deleteNode(static_cast(nodeId)); + + _undoStack->push(new RemoveNodeCommand(_model.get(), nodeId)); + return true; } void QuickGraphModel::addConnection(int outNodeId, int outPortIndex, int inNodeId, int inPortIndex) @@ -74,7 +228,7 @@ void QuickGraphModel::addConnection(int outNodeId, int outPortIndex, int inNodeI connId.inNodeId = static_cast(inNodeId); connId.inPortIndex = static_cast(inPortIndex); - _model->addConnection(connId); + _undoStack->push(new AddConnectionCommand(_model.get(), connId)); } void QuickGraphModel::removeConnection(int outNodeId, int outPortIndex, int inNodeId, int inPortIndex) @@ -86,7 +240,7 @@ void QuickGraphModel::removeConnection(int outNodeId, int outPortIndex, int inNo connId.inNodeId = static_cast(inNodeId); connId.inPortIndex = static_cast(inPortIndex); - _model->deleteConnection(connId); + _undoStack->push(new RemoveConnectionCommand(_model.get(), connId)); } QVariantMap QuickGraphModel::getConnectionAtInput(int nodeId, int portIndex) @@ -137,4 +291,24 @@ bool QuickGraphModel::connectionPossible(int outNodeId, int outPortIndex, int in return _model->connectionPossible(connId); } +bool QuickGraphModel::canUndo() const +{ + return _undoStack->canUndo(); +} + +bool QuickGraphModel::canRedo() const +{ + return _undoStack->canRedo(); +} + +void QuickGraphModel::undo() +{ + _undoStack->undo(); +} + +void QuickGraphModel::redo() +{ + _undoStack->redo(); +} + } // namespace QtNodes From 175637b3caee866b975a78b6ebfd2086ea776458 Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Tue, 2 Dec 2025 23:58:45 -0300 Subject: [PATCH 30/33] better demo --- examples/qml_calculator/main.qml | 426 ++++++++++++++++++++++--------- 1 file changed, 303 insertions(+), 123 deletions(-) diff --git a/examples/qml_calculator/main.qml b/examples/qml_calculator/main.qml index 057407b95..13fc70e56 100644 --- a/examples/qml_calculator/main.qml +++ b/examples/qml_calculator/main.qml @@ -5,6 +5,7 @@ import QtQuick.Layouts 1.15 import QtNodes 1.0 Window { + id: mainWindow visible: true width: 1280 height: 800 @@ -45,146 +46,325 @@ Window { selectionRectBorder: "#2196F3" } - Column { + // Draggable node item component + component DraggableNodeButton: Rectangle { + id: dragButton + property string nodeType + property string label + property color accentColor: darkTheme ? "#888" : "#666" + + width: parent.width - 10 + height: 36 + radius: 4 + color: dragArea.containsMouse ? (darkTheme ? "#4a4a4a" : "#d0d0d0") : (darkTheme ? "#3a3a3a" : "#e8e8e8") + border.color: accentColor + border.width: 1 + + Text { + anchors.centerIn: parent + text: label + color: accentColor + font.pixelSize: 11 + font.bold: true + } + + MouseArea { + id: dragArea + anchors.fill: parent + hoverEnabled: true + + property point startPos + property bool isDragging: false + + onPressed: (mouse) => { + startPos = Qt.point(mouse.x, mouse.y) + isDragging = false + } + + onPositionChanged: (mouse) => { + if (pressed) { + var delta = Qt.point(mouse.x - startPos.x, mouse.y - startPos.y) + if (!isDragging && (Math.abs(delta.x) > 5 || Math.abs(delta.y) > 5)) { + isDragging = true + dragProxy.nodeType = nodeType + dragProxy.label = label + dragProxy.accentColor = accentColor + dragProxy.visible = true + } + if (isDragging) { + var globalPos = mapToItem(mainWindow.contentItem, mouse.x, mouse.y) + dragProxy.x = globalPos.x - dragProxy.width / 2 + dragProxy.y = globalPos.y - dragProxy.height / 2 + } + } + } + + onReleased: (mouse) => { + if (isDragging) { + var globalPos = mapToItem(mainWindow.contentItem, mouse.x, mouse.y) + var canvasPos = mapToItem(nodeGraph, mouse.x, mouse.y) + + // Check if dropped on canvas + if (canvasPos.x > 0 && canvasPos.y > 0 && + canvasPos.x < nodeGraph.width && canvasPos.y < nodeGraph.height) { + // Convert to canvas coordinates considering zoom and pan + var graphPos = nodeGraph.mapToCanvas(canvasPos.x, canvasPos.y) + var nodeId = model.addNode(nodeType) + if (nodeId >= 0) { + model.nodes.moveNode(nodeId, graphPos.x - 75, graphPos.y - 40) + } + } + dragProxy.visible = false + } + isDragging = false + } + } + } + + // Drag proxy that follows the mouse + Rectangle { + id: dragProxy + visible: false + width: 120 + height: 36 + radius: 4 + z: 1000 + opacity: 0.8 + + property string nodeType + property string label + property color accentColor + + color: darkTheme ? "#3a3a3a" : "#e8e8e8" + border.color: accentColor + border.width: 2 + + Text { + anchors.centerIn: parent + text: dragProxy.label + color: dragProxy.accentColor + font.pixelSize: 11 + font.bold: true + } + } + + Row { anchors.fill: parent - // Toolbar with node buttons + // Left sidebar with node palette Rectangle { - width: parent.width - height: 50 - color: darkTheme ? "#3c3c3c" : "#e0e0e0" + id: sidebar + width: 140 + height: parent.height + color: darkTheme ? "#2d2d2d" : "#f0f0f0" - RowLayout { + Column { anchors.fill: parent - anchors.margins: 5 - spacing: 10 - - Button { - text: darkTheme ? "☀ Light" : "🌙 Dark" - onClicked: darkTheme = !darkTheme - } + spacing: 0 - Rectangle { width: 1; height: 30; color: darkTheme ? "#555" : "#bbb" } - - Button { - text: "↶ Undo" - enabled: model.canUndo - onClicked: model.undo() - } - Button { - text: "↷ Redo" - enabled: model.canRedo - onClicked: model.redo() - } - - Rectangle { width: 1; height: 30; color: darkTheme ? "#555" : "#bbb" } - - Label { - text: "Numbers:" - color: darkTheme ? "#aaa" : "#555" - font.bold: true - } - Button { - text: "Number" - onClicked: model.addNode("NumberSource") - palette.buttonText: "#4CAF50" - } - - Rectangle { width: 1; height: 30; color: darkTheme ? "#555" : "#bbb" } - - Label { - text: "Math:" - color: darkTheme ? "#aaa" : "#555" - font.bold: true - } - Button { text: "Add"; onClicked: model.addNode("Addition") } - Button { text: "Subtract"; onClicked: model.addNode("Subtract") } - Button { text: "Multiply"; onClicked: model.addNode("Multiply") } - Button { text: "Divide"; onClicked: model.addNode("Divide") } - - Rectangle { width: 1; height: 30; color: darkTheme ? "#555" : "#bbb" } - - Label { - text: "String:" - color: darkTheme ? "#aaa" : "#555" - font.bold: true - } - Button { - text: "Format" - onClicked: model.addNode("FormatNumber") - palette.buttonText: "#FF9800" - } - Button { - text: "Text Display" - onClicked: model.addNode("StringDisplay") - palette.buttonText: "#FF9800" - } - - Rectangle { width: 1; height: 30; color: darkTheme ? "#555" : "#bbb" } - - Label { - text: "Integer:" - color: darkTheme ? "#aaa" : "#555" - font.bold: true - } - Button { - text: "Int" - onClicked: model.addNode("IntegerSource") - palette.buttonText: "#2196F3" - } - Button { - text: "To Int" - onClicked: model.addNode("ToInteger") - palette.buttonText: "#2196F3" - } - - Rectangle { width: 1; height: 30; color: darkTheme ? "#555" : "#bbb" } - - Label { - text: "Logic:" - color: darkTheme ? "#aaa" : "#555" - font.bold: true - } - Button { - text: "A > B" - onClicked: model.addNode("GreaterThan") - palette.buttonText: "#9C27B0" + // Top toolbar section + Rectangle { + width: parent.width + height: 50 + color: darkTheme ? "#3c3c3c" : "#e0e0e0" + + Row { + anchors.centerIn: parent + spacing: 5 + + Button { + width: 36 + height: 36 + text: darkTheme ? "☀" : "🌙" + onClicked: darkTheme = !darkTheme + ToolTip.visible: hovered + ToolTip.text: darkTheme ? "Light Theme" : "Dark Theme" + } + + Button { + width: 36 + height: 36 + text: "↶" + enabled: model.canUndo + onClicked: model.undo() + ToolTip.visible: hovered + ToolTip.text: "Undo (Ctrl+Z)" + } + + Button { + width: 36 + height: 36 + text: "↷" + enabled: model.canRedo + onClicked: model.redo() + ToolTip.visible: hovered + ToolTip.text: "Redo (Ctrl+Y)" + } + } } - Rectangle { width: 1; height: 30; color: darkTheme ? "#555" : "#bbb" } - - Label { - text: "Display:" - color: darkTheme ? "#aaa" : "#555" - font.bold: true - } - Button { - text: "Decimal" - onClicked: model.addNode("NumberDisplay") - palette.buttonText: "#4CAF50" - } - Button { - text: "Int" - onClicked: model.addNode("IntegerDisplay") - palette.buttonText: "#2196F3" - } - Button { - text: "Bool" - onClicked: model.addNode("BooleanDisplay") - palette.buttonText: "#9C27B0" + // Scrollable node palette + ScrollView { + width: parent.width + height: parent.height - 50 + clip: true + + Column { + width: sidebar.width + spacing: 5 + padding: 5 + + // Numbers section + Label { + text: "NUMBERS" + color: darkTheme ? "#888" : "#666" + font.bold: true + font.pixelSize: 10 + leftPadding: 5 + topPadding: 10 + } + + DraggableNodeButton { + nodeType: "NumberSource" + label: "Number" + accentColor: "#4CAF50" + } + + DraggableNodeButton { + nodeType: "IntegerSource" + label: "Integer" + accentColor: "#2196F3" + } + + // Math section + Label { + text: "MATH" + color: darkTheme ? "#888" : "#666" + font.bold: true + font.pixelSize: 10 + leftPadding: 5 + topPadding: 10 + } + + DraggableNodeButton { + nodeType: "Addition" + label: "Add (+)" + accentColor: darkTheme ? "#aaa" : "#555" + } + + DraggableNodeButton { + nodeType: "Subtract" + label: "Subtract (−)" + accentColor: darkTheme ? "#aaa" : "#555" + } + + DraggableNodeButton { + nodeType: "Multiply" + label: "Multiply (×)" + accentColor: darkTheme ? "#aaa" : "#555" + } + + DraggableNodeButton { + nodeType: "Divide" + label: "Divide (÷)" + accentColor: darkTheme ? "#aaa" : "#555" + } + + // Conversion section + Label { + text: "CONVERSION" + color: darkTheme ? "#888" : "#666" + font.bold: true + font.pixelSize: 10 + leftPadding: 5 + topPadding: 10 + } + + DraggableNodeButton { + nodeType: "ToInteger" + label: "To Integer" + accentColor: "#2196F3" + } + + DraggableNodeButton { + nodeType: "FormatNumber" + label: "Format Text" + accentColor: "#FF9800" + } + + // Logic section + Label { + text: "LOGIC" + color: darkTheme ? "#888" : "#666" + font.bold: true + font.pixelSize: 10 + leftPadding: 5 + topPadding: 10 + } + + DraggableNodeButton { + nodeType: "GreaterThan" + label: "A > B" + accentColor: "#9C27B0" + } + + // Display section + Label { + text: "DISPLAY" + color: darkTheme ? "#888" : "#666" + font.bold: true + font.pixelSize: 10 + leftPadding: 5 + topPadding: 10 + } + + DraggableNodeButton { + nodeType: "NumberDisplay" + label: "Decimal Display" + accentColor: "#4CAF50" + } + + DraggableNodeButton { + nodeType: "IntegerDisplay" + label: "Integer Display" + accentColor: "#2196F3" + } + + DraggableNodeButton { + nodeType: "BooleanDisplay" + label: "Boolean Display" + accentColor: "#9C27B0" + } + + DraggableNodeButton { + nodeType: "StringDisplay" + label: "Text Display" + accentColor: "#FF9800" + } + + // Spacer at bottom + Item { width: 1; height: 20 } + } } - - Item { Layout.fillWidth: true } } } + // Main canvas area NodeGraph { id: nodeGraph - width: parent.width - height: parent.height - 50 + width: parent.width - sidebar.width + height: parent.height graphModel: model style: darkTheme ? darkStyle : lightStyle + // Helper function to convert screen coords to canvas coords + function mapToCanvas(screenX, screenY) { + return Qt.point( + (screenX - panOffset.x) / zoomLevel, + (screenY - panOffset.y) / zoomLevel + ) + } + Component.onCompleted: { // Create a demo graph: (5 + 3) * 2 = 16, formatted as text var num1 = model.addNode("NumberSource") From 3b0bf60a5a42120d42d3463b96a3e1f63b43249b Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Wed, 3 Dec 2025 22:15:30 -0300 Subject: [PATCH 31/33] update QML opt in --- CMakeLists.txt | 2 +- include/QtNodes/qml/ConnectionsListModel.hpp | 3 ++- include/QtNodes/qml/NodesListModel.hpp | 3 ++- include/QtNodes/qml/QuickGraphModel.hpp | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2c5cc4eb0..5ec37e000 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,7 +33,7 @@ option(BUILD_SHARED_LIBS "Build as shared library" ON) option(BUILD_DEBUG_POSTFIX_D "Append d suffix to debug libraries" OFF) option(QT_NODES_FORCE_TEST_COLOR "Force colorized unit test output" OFF) option(USE_QT6 "Build with Qt6 (Enabled by default)" ON) -option(BUILD_QML "Build QML support" ON) +option(BUILD_QML "Build QML support" OFF) if(QT_NODES_DEVELOPER_DEFAULTS) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}/bin") diff --git a/include/QtNodes/qml/ConnectionsListModel.hpp b/include/QtNodes/qml/ConnectionsListModel.hpp index b073e97cd..d7b05b5f3 100644 --- a/include/QtNodes/qml/ConnectionsListModel.hpp +++ b/include/QtNodes/qml/ConnectionsListModel.hpp @@ -3,12 +3,13 @@ #include #include #include "QtNodes/internal/Definitions.hpp" +#include "QtNodes/internal/Export.hpp" namespace QtNodes { class AbstractGraphModel; -class ConnectionsListModel : public QAbstractListModel +class NODE_EDITOR_PUBLIC ConnectionsListModel : public QAbstractListModel { Q_OBJECT public: diff --git a/include/QtNodes/qml/NodesListModel.hpp b/include/QtNodes/qml/NodesListModel.hpp index c5b4dba80..b53725529 100644 --- a/include/QtNodes/qml/NodesListModel.hpp +++ b/include/QtNodes/qml/NodesListModel.hpp @@ -4,12 +4,13 @@ #include #include #include "QtNodes/internal/Definitions.hpp" +#include "QtNodes/internal/Export.hpp" namespace QtNodes { class AbstractGraphModel; -class NodesListModel : public QAbstractListModel +class NODE_EDITOR_PUBLIC NodesListModel : public QAbstractListModel { Q_OBJECT public: diff --git a/include/QtNodes/qml/QuickGraphModel.hpp b/include/QtNodes/qml/QuickGraphModel.hpp index db98ab42e..b7f234e0a 100644 --- a/include/QtNodes/qml/QuickGraphModel.hpp +++ b/include/QtNodes/qml/QuickGraphModel.hpp @@ -5,6 +5,7 @@ #include "NodesListModel.hpp" #include "ConnectionsListModel.hpp" +#include "QtNodes/internal/Export.hpp" class QUndoStack; @@ -13,7 +14,7 @@ namespace QtNodes { class DataFlowGraphModel; class NodeDelegateModelRegistry; -class QuickGraphModel : public QObject +class NODE_EDITOR_PUBLIC QuickGraphModel : public QObject { Q_OBJECT Q_PROPERTY(QtNodes::NodesListModel* nodes READ nodes CONSTANT) From f028dd877236a94a68e923773d452fd4a6c21d47 Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Tue, 9 Dec 2025 21:56:55 -0300 Subject: [PATCH 32/33] claude added --- CLAUDE.md | 533 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..b19b848c5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,533 @@ +# CLAUDE.md - NodeEditor QML + +Editor visual de nodes baseado em QtNodes com suporte completo a Qt Quick/QML. + +## Visao Geral + +nodeeditor_qml e um fork do QtNodes com suporte adicional a QML. Permite criar interfaces visuais de programacao (node-based) como: +- Editores de shaders +- Pipelines de processamento de dados +- Sistemas de estrategias de trading +- Blueprints de logica + +## Arquitetura MVVM + +``` +┌─────────────────────────────────────────────────────────────┐ +│ QML (View) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ NodeGraph │ │ Node │ │ Connection │ │ +│ │ .qml │ │ .qml │ │ .qml │ │ +│ └──────┬──────┘ └──────┬──────┘ └────────┬────────────┘ │ +└─────────┼────────────────┼──────────────────┼──────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────┐ +│ C++ Models (ViewModel) │ +│ ┌─────────────────┐ ┌──────────────────────────────────┐ │ +│ │ QuickGraphModel │ │ NodesListModel │ │ +│ │ (controller) │ │ ConnectionsListModel │ │ +│ └────────┬────────┘ └──────────────────────────────────┘ │ +└───────────┼─────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ DataFlowGraphModel (Model) │ +│ NodeDelegateModelRegistry │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Estrutura de Arquivos + +``` +nodeeditor_qml/ +├── CMakeLists.txt +├── include/QtNodes/ +│ ├── internal/ # Core classes (Qt Widgets) +│ │ ├── NodeDelegateModel.hpp # Base para nodes customizados +│ │ ├── NodeDelegateModelRegistry.hpp +│ │ ├── DataFlowGraphModel.hpp # Modelo de dados principal +│ │ ├── NodeData.hpp # Dados transferidos entre nodes +│ │ └── ... +│ └── qml/ # QML-specific +│ ├── QuickGraphModel.hpp # Controller QML +│ ├── NodesListModel.hpp # Lista de nodes para Repeater +│ └── ConnectionsListModel.hpp # Lista de conexoes +├── src/ +│ ├── *.cpp # Implementacoes core +│ └── qml/*.cpp # Implementacoes QML +├── resources/ +│ ├── qml/ +│ │ ├── NodeGraph.qml # Canvas principal +│ │ ├── Node.qml # Componente node +│ │ ├── Connection.qml # Curvas Bezier +│ │ └── NodeGraphStyle.qml # Theming +│ └── qml.qrc +└── examples/ + └── qml_calculator/ # Exemplo de calculadora +``` + +## Build + +### Opcoes CMake + +```cmake +# IMPORTANTE: Habilitar suporte QML +set(BUILD_QML ON CACHE BOOL "" FORCE) + +# Biblioteca estatica (recomendado para embedding) +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + +add_subdirectory(nodeeditor_qml) +target_link_libraries(meu_app PRIVATE QtNodes::QtNodes) +``` + +### Targets + +| Target | Descricao | +|--------|-----------| +| `QtNodes` | Biblioteca principal | +| `QtNodes::QtNodes` | Alias para linkagem | + +## Uso Basico em QML + +### 1. Registrar Tipos + +```cpp +// main.cpp +#include +#include +#include +#include +#include +#include "MeuNode.hpp" + +int main(int argc, char *argv[]) { + // ... + + // Registrar tipos QML + qmlRegisterType("QtNodes", 1, 0, "QuickGraphModel"); + qmlRegisterType("QtNodes", 1, 0, "NodesListModel"); + qmlRegisterType("QtNodes", 1, 0, "ConnectionsListModel"); + qmlRegisterType("QtNodes", 1, 0, "NodeGraphStyle"); + + // Carregar recursos QML do QtNodes + Q_INIT_RESOURCE(qml); + + // Criar registry e registrar nodes customizados + auto registry = std::make_shared(); + registry->registerModel("Minha Categoria"); + + // Criar graph model + auto graphModel = new QtNodes::QuickGraphModel(); + graphModel->setRegistry(registry); + + // Expor para QML + engine.rootContext()->setContextProperty("_graphModel", graphModel); + + // ... +} +``` + +### 2. Usar no QML + +```qml +import QtQuick 2.15 +import QtNodes 1.0 + +ApplicationWindow { + NodeGraph { + anchors.fill: parent + graphModel: _graphModel + + // Theming customizado (opcional) + style: NodeGraphStyle { + canvasBackground: "#1e1e1e" + nodeBackground: "#2d2d2d" + nodeSelectedBorder: "#4a9eff" + } + } +} +``` + +## Criando Nodes Customizados + +### 1. Definir Tipo de Dados + +```cpp +// MeuDado.hpp +#include + +using QtNodes::NodeData; +using QtNodes::NodeDataType; + +class DecimalData : public NodeData +{ +public: + DecimalData() : _value(0.0) {} + DecimalData(double value) : _value(value) {} + + NodeDataType type() const override { + return NodeDataType{"decimal", "Decimal"}; + } + + double value() const { return _value; } + +private: + double _value; +}; +``` + +### 2. Implementar NodeDelegateModel + +```cpp +// MeuNode.hpp +#include +#include "MeuDado.hpp" + +using QtNodes::NodeDelegateModel; +using QtNodes::PortType; +using QtNodes::PortIndex; + +class MeuNode : public NodeDelegateModel +{ + Q_OBJECT + +public: + MeuNode() : _result(0.0) {} + + // Identificacao + QString caption() const override { return "Meu Node"; } + QString name() const override { return "MeuNode"; } + + // Portas + unsigned int nPorts(PortType portType) const override { + return portType == PortType::In ? 2 : 1; // 2 inputs, 1 output + } + + NodeDataType dataType(PortType, PortIndex) const override { + return DecimalData{}.type(); + } + + // Dados + std::shared_ptr outData(PortIndex) override { + return std::make_shared(_result); + } + + void setInData(std::shared_ptr data, PortIndex portIndex) override { + auto decimalData = std::dynamic_pointer_cast(data); + + if (portIndex == 0) { + _input1 = decimalData ? decimalData->value() : 0.0; + } else { + _input2 = decimalData ? decimalData->value() : 0.0; + } + + compute(); + } + + void compute() { + _result = _input1 + _input2; // Exemplo: soma + emit dataUpdated(0); // Notificar output + } + + // Widget embarcado (opcional) + QWidget* embeddedWidget() override { return nullptr; } + +private: + double _input1 = 0.0; + double _input2 = 0.0; + double _result = 0.0; +}; +``` + +### 3. Registrar no Registry + +```cpp +auto registry = std::make_shared(); + +// Forma simples +registry->registerModel("Categoria"); + +// Com factory customizada +registry->registerModel( + []() { return std::make_unique(); }, + "Categoria" +); +``` + +## API QuickGraphModel + +### Propriedades QML + +| Propriedade | Tipo | Descricao | +|-------------|------|-----------| +| `nodes` | NodesListModel* | Lista de nodes | +| `connections` | ConnectionsListModel* | Lista de conexoes | +| `canUndo` | bool | Tem acao para desfazer? | +| `canRedo` | bool | Tem acao para refazer? | + +### Metodos Invocaveis (Q_INVOKABLE) + +```qml +// Adicionar node +var nodeId = graphModel.addNode("MeuNode") + +// Remover node +graphModel.removeNode(nodeId) + +// Criar conexao (output -> input) +graphModel.addConnection(outNodeId, outPortIndex, inNodeId, inPortIndex) + +// Remover conexao +graphModel.removeConnection(outNodeId, outPortIndex, inNodeId, inPortIndex) + +// Verificar se conexao e possivel +var possible = graphModel.connectionPossible(outNodeId, outPort, inNodeId, inPort) + +// Obter tipo de dados da porta +var typeId = graphModel.getPortDataTypeId(nodeId, portType, portIndex) + +// Undo/Redo +graphModel.undo() +graphModel.redo() +``` + +### NodesListModel Roles + +| Role | ID | Tipo | Descricao | +|------|-----|------|-----------| +| NodeIdRole | 256 | int | ID unico do node | +| NodeTypeRole | 257 | QString | Nome do tipo | +| PositionRole | 258 | QPointF | Posicao no canvas | +| CaptionRole | 259 | QString | Titulo visivel | +| InPortsRole | 260 | QVariantList | Portas de entrada | +| OutPortsRole | 261 | QVariantList | Portas de saida | +| DelegateModelRole | 262 | QObject* | NodeDelegateModel* | + +### Mover Node + +```qml +// Mover node para posicao +graphModel.nodes.moveNode(nodeId, x, y) +``` + +## Componentes QML + +### NodeGraph + +Canvas principal com pan/zoom infinito e grade. + +```qml +NodeGraph { + graphModel: _graphModel + style: NodeGraphStyle { } + nodeContentDelegate: Component { /* conteudo customizado */ } + + // Propriedades + zoomLevel: 1.0 + panOffset: Qt.point(0, 0) + selectedNodeIds: ({}) + + // Funcoes + function selectNode(nodeId, additive) { } + function clearSelection() { } + function deleteSelected() { } + function getSelectedNodeIds() { return [] } +} +``` + +### Node + +Componente visual de node individual. + +```qml +Node { + graph: nodeGraphRef + nodeId: model.nodeId + nodeType: model.nodeType + caption: model.caption + inPorts: model.inPorts + outPorts: model.outPorts + delegateModel: model.delegateModel + contentDelegate: customContentComponent +} +``` + +### Connection + +Curva Bezier conectando portas. + +```qml +Connection { + graph: nodeGraphRef + sourceNodeId: 1 + sourcePortIndex: 0 + destNodeId: 2 + destPortIndex: 0 +} +``` + +### NodeGraphStyle + +Tema customizavel. + +```qml +NodeGraphStyle { + // Canvas + canvasBackground: "#2b2b2b" + gridMinorLine: "#353535" + gridMajorLine: "#151515" + gridMinorSpacing: 20 + gridMajorSpacing: 100 + + // Node + nodeBackground: "#2d2d2d" + nodeBorder: "black" + nodeSelectedBorder: "#4a9eff" + nodeBorderWidth: 2 + nodeSelectedBorderWidth: 3 + nodeRadius: 5 + nodeCaptionColor: "#eeeeee" + nodeCaptionFontSize: 12 + + // Portas + portSize: 12 + portTypeColors: ({ + "decimal": "#4CAF50", + "integer": "#2196F3", + "string": "#FF9800", + "boolean": "#9C27B0", + "default": "#9E9E9E" + }) + + // Conexoes + connectionWidth: 3 + connectionSelectedWidth: 4 + connectionSelectionOutline: "#4a9eff" + + // Selecao + selectionRectFill: "#224a9eff" + selectionRectBorder: "#4a9eff" +} +``` + +## Atalhos de Teclado + +| Atalho | Acao | +|--------|------| +| Delete / Backspace / X | Deletar selecionados | +| Ctrl+Z | Desfazer | +| Ctrl+Shift+Z / Ctrl+Y | Refazer | +| Ctrl+Click | Selecao aditiva | +| Alt+Drag | Pan (alternativo) | +| Mouse wheel | Zoom (centrado no cursor) | +| Middle mouse drag | Pan | +| Left drag no canvas | Selecao marquee | + +## Validacao de Conexoes + +Conexoes so sao criadas se os tipos de dados forem compativeis: + +```cpp +// No NodeDelegateModel +NodeDataType dataType(PortType, PortIndex) const override { + return NodeDataType{"decimal", "Decimal"}; +} +``` + +Nodes com tipos diferentes nao podem ser conectados. O sistema valida automaticamente usando `NodeData::sameType()`. + +## Serializacao + +### Salvar + +```cpp +// DataFlowGraphModel tem suporte a save/load +auto model = graphModel->graphModel(); +QJsonObject json = model->save(); +``` + +### Carregar + +```cpp +model->load(json); +``` + +## Undo/Redo + +O sistema usa QUndoStack internamente. Operacoes suportadas: +- Adicionar/remover nodes +- Criar/remover conexoes +- Mover nodes + +```qml +// Verificar estado +if (graphModel.canUndo) graphModel.undo() +if (graphModel.canRedo) graphModel.redo() +``` + +## Exemplo Completo: Calculadora + +```cpp +// AddNode.hpp +class AddNode : public NodeDelegateModel { + Q_OBJECT +public: + QString caption() const override { return "Add"; } + QString name() const override { return "Add"; } + + unsigned int nPorts(PortType pt) const override { + return pt == PortType::In ? 2 : 1; + } + + NodeDataType dataType(PortType, PortIndex) const override { + return DecimalData{}.type(); + } + + void setInData(std::shared_ptr data, PortIndex idx) override { + auto d = std::dynamic_pointer_cast(data); + if (idx == 0) _a = d ? d->value() : 0; + else _b = d ? d->value() : 0; + compute(); + } + + std::shared_ptr outData(PortIndex) override { + return std::make_shared(_a + _b); + } + + QWidget* embeddedWidget() override { return nullptr; } + +private: + void compute() { emit dataUpdated(0); } + double _a = 0, _b = 0; +}; +``` + +## Troubleshooting + +### "NodeGraphStyle unavailable" +Adicione no main.cpp antes de carregar QML: +```cpp +Q_INIT_RESOURCE(qml); +``` + +### Nodes nao aparecem +Verifique se `BUILD_QML=ON` no CMake e se o registry foi configurado corretamente. + +### Conexoes nao sao criadas +Verifique se os tipos de dados (`NodeDataType.id`) sao compativeis entre as portas. + +### Drag de conexao nao funciona +Certifique-se de que `focus: true` esta no NodeGraph. + +### Performance com muitos nodes +- Use `visible: false` para nodes fora da viewport +- Reduza a frequencia de atualizacoes em `dataUpdated` +- Considere pooling de conexoes + +## Referencias + +- [QtNodes original](https://github.com/paceholder/nodeeditor) +- [Dear ImGui Node Editor](https://github.com/thedmd/imgui-node-editor) (alternativa) +- [Qt Quick Controls](https://doc.qt.io/qt-6/qtquickcontrols-index.html) From de7072c9e4661df19be941454b4c4b71b83fadd9 Mon Sep 17 00:00:00 2001 From: Tato Levicz Date: Thu, 11 Dec 2025 13:17:33 -0300 Subject: [PATCH 33/33] added double click support --- resources/qml/Node.qml | 8 +++++++- resources/qml/NodeGraph.qml | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/resources/qml/Node.qml b/resources/qml/Node.qml index ba10a541f..928fd600c 100644 --- a/resources/qml/Node.qml +++ b/resources/qml/Node.qml @@ -41,13 +41,19 @@ Rectangle { TapHandler { onTapped: (eventPoint, button) => { graph.forceActiveFocus() - var additive = (eventPoint.event.modifiers & Qt.ControlModifier) + var additive = eventPoint && eventPoint.event ? (eventPoint.event.modifiers & Qt.ControlModifier) : false if (additive) { graph.toggleNodeSelection(nodeId) } else { graph.selectNode(nodeId, false) } } + onDoubleTapped: (eventPoint, button) => { + // Emit signal for node configuration + if (graph && graph.nodeDoubleClicked) { + graph.nodeDoubleClicked(nodeId, nodeType, delegateModel) + } + } } // Separate handler for pointer press to handle selection on mouse down diff --git a/resources/qml/NodeGraph.qml b/resources/qml/NodeGraph.qml index 2f4fb473a..80c91144b 100644 --- a/resources/qml/NodeGraph.qml +++ b/resources/qml/NodeGraph.qml @@ -29,6 +29,7 @@ Item { } signal nodeRegistryChanged() + signal nodeDoubleClicked(int nodeId, string nodeType, var delegateModel) // Zoom and Pan property real zoomLevel: 1.0