diff --git a/.gitignore b/.gitignore index 552a9b2f..9829c624 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ *.pyc CMakeLists.txt.user - +.idea build*/ .vscode/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..b19b848c --- /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) diff --git a/CMakeLists.txt b/CMakeLists.txt index cf036012..5ec37e00 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" OFF) 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 QuickControls2) + 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/README_QML.md b/README_QML.md new file mode 100644 index 00000000..455415ac --- /dev/null +++ b/README_QML.md @@ -0,0 +1,168 @@ +# 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. 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** (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 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, 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. + * 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 (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 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 + +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 +``` + +## 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/CMakeLists.txt b/examples/CMakeLists.txt index 49494da2..3816f01f 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 00000000..ebe14b93 --- /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/BooleanData.hpp b/examples/qml_calculator/BooleanData.hpp new file mode 100644 index 00000000..f3473b72 --- /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 00000000..dfde860b --- /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 new file mode 100644 index 00000000..67158041 --- /dev/null +++ b/examples/qml_calculator/CMakeLists.txt @@ -0,0 +1,33 @@ +set(TARGET_NAME qml_calculator) + +add_executable(${TARGET_NAME} + main.cpp + QmlCalculatorResources.qrc + 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} + 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/DecimalData.hpp b/examples/qml_calculator/DecimalData.hpp new file mode 100644 index 00000000..caefe316 --- /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/DivideModel.hpp b/examples/qml_calculator/DivideModel.hpp new file mode 100644 index 00000000..65baf266 --- /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 00000000..f4d1b26c --- /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 00000000..520c4139 --- /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 00000000..ba7cd2df --- /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 00000000..8762813b --- /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 00000000..b17f8cc9 --- /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 00000000..ffbf365a --- /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/QmlCalculatorResources.qrc b/examples/qml_calculator/QmlCalculatorResources.qrc new file mode 100644 index 00000000..5f6483ac --- /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 00000000..a0a946af --- /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 00000000..39b02a54 --- /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/StringData.hpp b/examples/qml_calculator/StringData.hpp new file mode 100644 index 00000000..f1c2ad2d --- /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 00000000..bd84ab8d --- /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 00000000..e99701b6 --- /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 00000000..0de99f0f --- /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 new file mode 100644 index 00000000..85ab96c5 --- /dev/null +++ b/examples/qml_calculator/main.cpp @@ -0,0 +1,82 @@ +#include +#include +#include +#include + +#include +#include +#include +#include + +#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; + +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; +} + +int main(int argc, char *argv[]) +{ + QQuickStyle::setStyle("Fusion"); + 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"); + qmlRegisterType(QUrl("qrc:/QtNodes/QML/qml/NodeGraphStyle.qml"), "QtNodes", 1, 0, "NodeGraphStyle"); + + 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 00000000..13fc70e5 --- /dev/null +++ b/examples/qml_calculator/main.qml @@ -0,0 +1,581 @@ +import QtQuick 2.15 +import QtQuick.Window 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import QtNodes 1.0 + +Window { + id: mainWindow + visible: true + width: 1280 + height: 800 + title: "QML Calculator - Extended" + + 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" + } + + // 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 + + // Left sidebar with node palette + Rectangle { + id: sidebar + width: 140 + height: parent.height + color: darkTheme ? "#2d2d2d" : "#f0f0f0" + + Column { + anchors.fill: parent + spacing: 0 + + // 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)" + } + } + } + + // 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 } + } + } + } + } + + // Main canvas area + NodeGraph { + id: nodeGraph + 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") + 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 (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) + } + } + + nodeContentDelegate: Component { + Item { + property var delegateModel + property string nodeType + property var contentColor: nodeGraph.style.nodeContentColor + + // NumberSource - editable number input + 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) + } + onActiveFocusChanged: { + if (activeFocus) selectAll() + } + color: "black" + 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: "#4CAF50" + font.pixelSize: 18 + font.bold: true + } + + // Math operation symbols + Text { + anchors.centerIn: parent + visible: nodeType === "Addition" + text: "+" + color: contentColor + font.pixelSize: 36 + font.bold: true + } + + Text { + anchors.centerIn: parent + visible: nodeType === "Subtract" + text: "−" + color: contentColor + font.pixelSize: 36 + font.bold: true + } + + Text { + anchors.centerIn: parent + visible: nodeType === "Multiply" + text: "×" + color: contentColor + font.pixelSize: 36 + font.bold: true + } + + Text { + anchors.centerIn: parent + visible: nodeType === "Divide" + text: "÷" + color: contentColor + 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/ConnectionsListModel.hpp b/include/QtNodes/qml/ConnectionsListModel.hpp new file mode 100644 index 00000000..d7b05b5f --- /dev/null +++ b/include/QtNodes/qml/ConnectionsListModel.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include "QtNodes/internal/Definitions.hpp" +#include "QtNodes/internal/Export.hpp" + +namespace QtNodes { + +class AbstractGraphModel; + +class NODE_EDITOR_PUBLIC 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 00000000..b5372552 --- /dev/null +++ b/include/QtNodes/qml/NodesListModel.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include "QtNodes/internal/Definitions.hpp" +#include "QtNodes/internal/Export.hpp" + +namespace QtNodes { + +class AbstractGraphModel; + +class NODE_EDITOR_PUBLIC 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 00000000..b7f234e0 --- /dev/null +++ b/include/QtNodes/qml/QuickGraphModel.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include +#include + +#include "NodesListModel.hpp" +#include "ConnectionsListModel.hpp" +#include "QtNodes/internal/Export.hpp" + +class QUndoStack; + +namespace QtNodes { + +class DataFlowGraphModel; +class NodeDelegateModelRegistry; + +class NODE_EDITOR_PUBLIC 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); + ~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); + 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.qrc b/resources/qml.qrc new file mode 100644 index 00000000..c3c8d4b4 --- /dev/null +++ b/resources/qml.qrc @@ -0,0 +1,8 @@ + + + qml/NodeGraph.qml + qml/Node.qml + qml/Connection.qml + qml/NodeGraphStyle.qml + + diff --git a/resources/qml/Connection.qml b/resources/qml/Connection.qml new file mode 100644 index 00000000..217b86ef --- /dev/null +++ b/resources/qml/Connection.qml @@ -0,0 +1,184 @@ +import QtQuick 2.15 +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 + property int destNodeId: -1 + property int destPortIndex: -1 + + 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 + + property string portTypeId: graph.getPortTypeId(sourceNodeId, 1, sourcePortIndex) + property color lineColor: graph.getPortColor(portTypeId) + + Connections { + target: graph + function onNodeRegistryChanged() { + sourceNode = graph.nodeItems[sourceNodeId] + destNode = graph.nodeItems[destNodeId] + } + function onConnectionSelectionChanged() { + selected = graph.isConnectionSelected(sourceNodeId, sourcePortIndex, destNodeId, destPortIndex) + } + } + + Connections { + target: sourceNode + function onXChanged() { root.updatePositions() } + function onYChanged() { root.updatePositions() } + } + Connections { + target: destNode + function onXChanged() { root.updatePositions() } + function onYChanged() { root.updatePositions() } + } + + property point startPos: Qt.point(0,0) + property point endPos: Qt.point(0,0) + + function updatePositions() { + if (sourceNode && sourceNode.completed) { + startPos = sourceNode.getPortPos(1, sourcePortIndex) + } + if (destNode && destNode.completed) { + endPos = destNode.getPortPos(0, destPortIndex) + } + } + + onSourceNodeChanged: updatePositions() + onDestNodeChanged: updatePositions() + + Component.onCompleted: updatePositions() + + visible: sourceNode !== undefined && destNode !== undefined && sourceNode.completed && destNode.completed + + // 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 + + 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: style.connectionSelectionOutlineWidth + strokeColor: style.connectionSelectionOutline + 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 + + ShapePath { + strokeWidth: root.hovered ? style.connectionHoverWidth : style.connectionWidth + strokeColor: root.hovered ? Qt.lighter(root.lineColor, 1.3) : root.lineColor + 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 00000000..928fd600 --- /dev/null +++ b/resources/qml/Node.qml @@ -0,0 +1,339 @@ +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 + property bool selected: { + graph.selectionVersion + return graph.isNodeSelected(nodeId) + } + + // Style shortcut - use direct access for reactivity + property var style: graph ? graph.style : null + + x: initialX + y: initialY + + width: style.nodeMinWidth + height: Math.max(Math.max(inPorts, outPorts) * (style.portSize + style.nodePortSpacing) + style.nodeHeaderHeight + 5, 50) + + color: style.nodeBackground + border.color: selected ? style.nodeSelectedBorder : style.nodeBorder + border.width: selected ? style.nodeSelectedBorderWidth : style.nodeBorderWidth + radius: style.nodeRadius + + Component.onCompleted: { + completed = true + } + + TapHandler { + onTapped: (eventPoint, button) => { + graph.forceActiveFocus() + 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 + 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 + + property point lastPos: Qt.point(0, 0) + property bool isDraggingGroup: false + + onActiveChanged: { + if (active) { + 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 + } + } + + 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) + } + } + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 8 + text: caption + 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: style.nodeHeaderHeight + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width - 20 + height: parent.height - style.nodeHeaderHeight - 15 + sourceComponent: contentDelegate + + 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 }) + } + } + + 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 + Column { + id: inPortsColumn + z: 10 + anchors.left: parent.left + anchors.top: parent.top + anchors.topMargin: style.nodeHeaderHeight + anchors.leftMargin: -style.portSize / 2 + 1 + spacing: style.nodePortSpacing + + Repeater { + id: inRepeater + model: inPorts + delegate: Rectangle { + id: inPortRect + 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 ? 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: { + if (!graph.isDragging) { + parent.scale = style.portHoverScale + graph.setActivePort({nodeId: root.nodeId, portType: 0, portIndex: index}) + } + } + onExited: { + 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 === 0 && ap.portIndex === index + } + 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) + + 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) + graph.updateDraftConnection(mousePos) + } 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) + graph.updateDraftConnection(pos) + } + onReleased: { + graph.endDraftConnection() + } + } + } + } + } + + // Output Ports + Column { + id: outPortsColumn + z: 10 + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: style.nodeHeaderHeight + anchors.rightMargin: -style.portSize / 2 + 1 + spacing: style.nodePortSpacing + + Repeater { + id: outRepeater + model: outPorts + delegate: Rectangle { + id: outPortRect + 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 ? 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: { + if (!graph.isDragging) { + parent.scale = style.portHoverScale + graph.setActivePort({nodeId: root.nodeId, portType: 1, portIndex: index}) + } + } + onExited: { + 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 ? style.portActiveScale : 1.0 + + onPressed: (mouse) => { + var pos = mapToItem(graph.canvas, width/2, height/2) + graph.startDraftConnection(root.nodeId, 1, index, pos) + } + onPositionChanged: (mouse) => { + var pos = mapToItem(graph.canvas, mouse.x, mouse.y) + graph.updateDraftConnection(pos) + } + onReleased: { + graph.endDraftConnection() + } + } + } + } + } + + 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) + + 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 00000000..80c91144 --- /dev/null +++ b/resources/qml/NodeGraph.qml @@ -0,0 +1,598 @@ +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 alias canvas: canvas + + property var nodeItems: ({}) + property Component nodeContentDelegate // User provided content + + // Style - can be overridden by user + property NodeGraphStyle style: NodeGraphStyle {} + + function getPortColor(typeId) { + return style.getPortColor(typeId) + } + + function getPortTypeId(nodeId, portType, portIndex) { + if (!graphModel) return "default" + return graphModel.getPortDataTypeId(nodeId, portType, portIndex) || "default" + } + + function registerNode(id, item) { + nodeItems[id] = item + nodeRegistryChanged() + } + + signal nodeRegistryChanged() + signal nodeDoubleClicked(int nodeId, string nodeType, var delegateModel) + + // Zoom and Pan + property real zoomLevel: 1.0 + property point panOffset: Qt.point(0, 0) + + // Port dragging + property var activePort: null + + // Z-order management + property int topZ: 1 + function bringToFront(nodeItem) { + topZ++ + 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 = {} + clearConnectionSelection() + } + 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() + clearConnectionSelection() + } + + 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 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 || + r2.y > r1.y + r1.height || + r2.y + r2.height < r1.y) + } + + function getSelectedNodeIds() { + 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 + } 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 + } + } + + focus: true + + // 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) + property bool isDragging: false + + function startDraftConnection(nodeId, portType, portIndex, pos) { + dragStart = pos + 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) { + 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() { + isDragging = false + draftConnectionTypeId = "" + if (activePort && activeConnectionStart) { + var start = activeConnectionStart + var end = activePort + + var outNodeId, outPortIndex, inNodeId, inPortIndex + + // Determine Out -> In direction + if (start.portType === 1 && end.portType === 0) { + 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 + } + + // Only create connection if types are compatible + if (graphModel.connectionPossible(outNodeId, outPortIndex, inNodeId, inPortIndex)) { + graphModel.addConnection(outNodeId, outPortIndex, inNodeId, inPortIndex) + } + } + activeConnectionStart = null + } + + function setActivePort(portInfo) { + activePort = portInfo + } + + Rectangle { + anchors.fill: parent + color: style.canvasBackground + clip: true + + // Input Handler for Pan/Zoom/Selection + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.MiddleButton | Qt.LeftButton + property point lastPos: Qt.point(0, 0) + + 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) => { + 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) + } 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 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) { + var rect = Qt.rect(x, y, w, h) + root.selectNodesInRect(rect) + root.selectConnectionsInRect(rect) + } + root.isMarqueeSelecting = false + } + } + + onWheel: (wheel) => { + var zoomFactor = 1.1 + 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 + } + } + + // Grid Shader + /* + ShaderEffect { + anchors.fill: parent + property real zoom: root.zoomLevel + property point offset: root.panOffset + property size size: Qt.size(width, height) + + // 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 + 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") + ctx.clearRect(0, 0, width, height) + + ctx.lineWidth = 1 + + var gridSize = minorSpacing * zoom + var majorGridSize = majorSpacing * zoom + + var startX = (offset.x % gridSize) + var startY = (offset.y % gridSize) + + if (startX < 0) startX += gridSize + if (startY < 0) startY += gridSize + + // Minor lines + ctx.strokeStyle = minorColor + 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 = majorColor + 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 + 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 { + model: graphModel ? graphModel.connections : null + delegate: Connection { + graph: root + sourceNodeId: model.sourceNodeId + sourcePortIndex: model.sourcePortIndex + destNodeId: model.destNodeId + destPortIndex: model.destPortIndex + } + } + + // 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.delegateModel // The C++ QObject* + contentDelegate: root.nodeContentDelegate + + onXChanged: { + if (completed && Math.abs(x - initialX) > 0.1) graphModel.nodes.moveNode(nodeId, x, y) + } + onYChanged: { + if (completed && Math.abs(y - initialY) > 0.1) graphModel.nodes.moveNode(nodeId, x, y) + } + + Component.onCompleted: { + console.log("Node created. ID:", nodeId, "Caption:", caption, "In:", inPorts, "Out:", outPorts) + root.registerNode(nodeId, nodeDelegate) + } + } + } + + // Dragging Connection + Shape { + visible: root.isDragging + ShapePath { + strokeWidth: style.draftConnectionWidth + strokeColor: style.draftConnectionColor + 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 + } + } + } + + // 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: 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 00000000..44a0fe1f --- /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"] + } +} diff --git a/resources/shaders/grid.frag b/resources/shaders/grid.frag new file mode 100644 index 00000000..8ba28d42 --- /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); +} diff --git a/src/qml/ConnectionsListModel.cpp b/src/qml/ConnectionsListModel.cpp new file mode 100644 index 00000000..d1cc6f77 --- /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 00000000..d10401a7 --- /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] = "delegateModel"; + 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 00000000..17a5bdbd --- /dev/null +++ b/src/qml/QuickGraphModel.cpp @@ -0,0 +1,314 @@ +#include "QtNodes/qml/QuickGraphModel.hpp" + +#include "QtNodes/internal/DataFlowGraphModel.hpp" +#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() +{ + // 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; + + auto* cmd = new AddNodeCommand(_model.get(), nodeType); + _undoStack->push(cmd); + return cmd->nodeId(); +} + +bool QuickGraphModel::removeNode(int nodeId) +{ + if (!_model) return false; + + _undoStack->push(new RemoveNodeCommand(_model.get(), nodeId)); + return true; +} + +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); + + _undoStack->push(new AddConnectionCommand(_model.get(), 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); + + _undoStack->push(new RemoveConnectionCommand(_model.get(), 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; +} + +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); +} + +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