diff --git a/CMakeLists.txt b/CMakeLists.txt index cf036012f..e63c2e645 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -87,6 +87,8 @@ set(CPP_SOURCE_FILES src/StyleCollection.cpp src/UndoCommands.cpp src/locateNode.cpp + src/GroupGraphicsObject.cpp + src/NodeGroup.cpp resources/resources.qrc ) @@ -127,6 +129,8 @@ set(HPP_HEADER_FILES include/QtNodes/internal/DefaultVerticalNodeGeometry.hpp include/QtNodes/internal/NodeConnectionInteraction.hpp include/QtNodes/internal/UndoCommands.hpp + include/QtNodes/internal/NodeGroup.hpp + include/QtNodes/internal/GroupGraphicsObject.hpp ) # If we want to give the option to build a static library, @@ -165,6 +169,9 @@ target_compile_definitions(QtNodes QT_NO_KEYWORDS ) +if(MSVC) + string(REGEX REPLACE "/W[0-4]" "" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") +endif() target_compile_options(QtNodes PRIVATE diff --git a/README.rst b/README.rst index ee74f43b8..3a3dbbd9d 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,8 @@ QtNodes ####### -https://github.com/paceholder/nodeeditor/actions/workflows/cmake_build.yml/badge.svg +.. image:: https://github.com/paceholder/nodeeditor/actions/workflows/cmake_build.yml/badge.svg + :target: https://github.com/paceholder/nodeeditor/actions/workflows/cmake_build.yml Introduction ============ @@ -33,7 +34,7 @@ Warning Branches -------- -There are branchses ``v2`` and ``v3`` for versions ``2.x.x`` and ``3.x`` +There are branches ``v2`` and ``v3`` for versions ``2.x.x`` and ``3.x`` respectively. The branch ``master`` contains the latest dev state. @@ -73,7 +74,7 @@ Dependencies ------------ * Qt >5.15 -* CMake 3.8 +* CMake 3.11 * Catch2 @@ -117,7 +118,7 @@ For building a static lib use: :: - cmake .. -BUILD_SHARED_LIBS=off + cmake .. -DBUILD_SHARED_LIBS=off Linux ----- @@ -192,9 +193,9 @@ For detailed testing documentation, see the `Testing Guide `_ is a visual programming language for beginners that is unique in that it is an intuitive flow graph: -.. image:: docs/_static/chigraph.png +.. image:: assets/chigraph.png It features easy bindings to C/C++, package management, and a cool interface. @@ -309,4 +310,4 @@ Spkgen particle editor particles engine that uses a node-based interface to create particles effects for games -.. image:: docs/_static/spkgen.png +.. image:: assets/spkgen.png diff --git a/docs/_static/chigraph.png b/assets/chigraph.png similarity index 100% rename from docs/_static/chigraph.png rename to assets/chigraph.png diff --git a/docs/_static/showcase_CANdevStudio.png b/assets/showcase-candevstudio.png similarity index 100% rename from docs/_static/showcase_CANdevStudio.png rename to assets/showcase-candevstudio.png diff --git a/docs/_static/spkgen.png b/assets/spkgen.png similarity index 100% rename from docs/_static/spkgen.png rename to assets/spkgen.png diff --git a/docs/_static/vid1.png b/assets/vid1.png similarity index 100% rename from docs/_static/vid1.png rename to assets/vid1.png diff --git a/docs/Makefile b/docs/Makefile index d0c3cbf10..ed8809902 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -5,7 +5,7 @@ # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build -SOURCEDIR = source +SOURCEDIR = . BUILDDIR = build # Put it first so that "make" without argument is like "make help". diff --git a/docs/_static/diagrams/architecture-diagram.png b/docs/_static/diagrams/architecture-diagram.png new file mode 100644 index 000000000..a41abb215 Binary files /dev/null and b/docs/_static/diagrams/architecture-diagram.png differ diff --git a/docs/_static/diagrams/architecture-diagram.puml b/docs/_static/diagrams/architecture-diagram.puml new file mode 100644 index 000000000..4ab31e1f5 --- /dev/null +++ b/docs/_static/diagrams/architecture-diagram.puml @@ -0,0 +1,53 @@ +@startuml architecture-diagram +!theme plain +skinparam backgroundColor #2B2B2B +skinparam defaultFontColor #E0E0E0 +skinparam classBorderColor #606060 +skinparam classBackgroundColor #3C3F41 +skinparam arrowColor #A0A0A0 +skinparam noteBorderColor #606060 +skinparam noteBackgroundColor #4A4D4F + +skinparam rectangle { + BackgroundColor #3C3F41 + BorderColor #606060 + FontColor #E0E0E0 +} + +rectangle "Your Application" as app #4A5568 { + rectangle "Your Data\n(nodes, connections,\npositions, values)" as data #718096 +} + +rectangle "QtNodes Library" as lib #2D3748 { + rectangle "AbstractGraphModel\n(or DataFlowGraphModel)" as model #4A5568 + rectangle "BasicGraphicsScene\n(or DataFlowGraphicsScene)" as scene #4A5568 + rectangle "GraphicsView" as view #4A5568 +} + +rectangle "User Interface" as ui #1A202C { + rectangle "Window with\ninteractive nodes" as window #2D3748 +} + +data -right-> model : "implements" +model -right-> scene : "visualizes" +scene -right-> view : "displays in" +view -right-> window : "renders to" + +note bottom of model + You subclass this + to store your graph data +end note + +note bottom of scene + Automatically creates + graphics objects from + model data +end note + +note bottom of view + Handles zoom, pan, + selection, and + user interactions +end note + +@enduml diff --git a/docs/_static/diagrams/dataflow-diagram.png b/docs/_static/diagrams/dataflow-diagram.png new file mode 100644 index 000000000..d0a8b5593 Binary files /dev/null and b/docs/_static/diagrams/dataflow-diagram.png differ diff --git a/docs/_static/diagrams/dataflow-diagram.puml b/docs/_static/diagrams/dataflow-diagram.puml new file mode 100644 index 000000000..5e180e38f --- /dev/null +++ b/docs/_static/diagrams/dataflow-diagram.puml @@ -0,0 +1,48 @@ +@startuml dataflow-diagram +!theme plain +skinparam backgroundColor #2B2B2B +skinparam defaultFontColor #E0E0E0 +skinparam componentBorderColor #606060 +skinparam componentBackgroundColor #3C3F41 +skinparam arrowColor #4299E1 +skinparam noteBorderColor #606060 +skinparam noteBackgroundColor #4A4D4F + +skinparam rectangle { + BackgroundColor #3C3F41 + BorderColor #606060 + FontColor #E0E0E0 + RoundCorner 15 +} + +rectangle "Source Node\n(NumberSourceDataModel)" as source #48BB78 { +} + +rectangle "Operator Node\n(AdditionModel)" as operator #4299E1 { +} + +rectangle "Display Node\n(NumberDisplayDataModel)" as display #ED8936 { +} + +source -right-> operator : "outData()" +operator -right-> display : "outData()" + +note bottom of source + **Emits data** + Creates values + (e.g., number input) +end note + +note bottom of operator + **Processes data** + Receives via setInData() + Outputs via outData() +end note + +note bottom of display + **Consumes data** + Receives and shows + the final result +end note + +@enduml diff --git a/docs/_static/diagrams/dataflow-signals.png b/docs/_static/diagrams/dataflow-signals.png new file mode 100644 index 000000000..e2fbf6fb5 Binary files /dev/null and b/docs/_static/diagrams/dataflow-signals.png differ diff --git a/docs/_static/diagrams/dataflow-signals.puml b/docs/_static/diagrams/dataflow-signals.puml new file mode 100644 index 000000000..864a16470 --- /dev/null +++ b/docs/_static/diagrams/dataflow-signals.puml @@ -0,0 +1,51 @@ +@startuml dataflow-signals +!theme plain +skinparam backgroundColor #2B2B2B +skinparam defaultFontColor #E0E0E0 +skinparam sequenceArrowColor #4299E1 +skinparam sequenceLifeLineBorderColor #606060 +skinparam sequenceParticipantBorderColor #606060 +skinparam sequenceParticipantBackgroundColor #3C3F41 +skinparam sequenceBoxBorderColor #606060 +skinparam sequenceBoxBackgroundColor #2D3748 + +participant "Source\nDelegate" as src #48BB78 +participant "DataFlow\nGraphModel" as model #4A5568 +participant "Operator\nDelegate" as op #4299E1 +participant "Display\nDelegate" as disp #ED8936 + +src -> src : User changes\nvalue in widget +activate src +src -> src : emit dataUpdated(portIndex) +src -> model : [signal] dataUpdated +deactivate src +activate model + +model -> model : Lookup connections\nfrom this port +model -> op : setInData(data, portIndex) +deactivate model +activate op + +op -> op : Process input +op -> op : Calculate result +op -> op : emit dataUpdated(0) +op -> model : [signal] dataUpdated +deactivate op +activate model + +model -> model : Lookup connections\nfrom operator output +model -> disp : setInData(data, portIndex) +deactivate model +activate disp + +disp -> disp : Update display\nwidget +deactivate disp + +note over model + DataFlowGraphModel + routes data between + connected delegates + automatically +end note + +@enduml diff --git a/docs/_static/screenshots/calc-start.png b/docs/_static/screenshots/calc-start.png new file mode 100644 index 000000000..bc8bda63c Binary files /dev/null and b/docs/_static/screenshots/calc-start.png differ diff --git a/docs/_static/calculator.png b/docs/_static/screenshots/calculator.png similarity index 100% rename from docs/_static/calculator.png rename to docs/_static/screenshots/calculator.png diff --git a/docs/_static/screenshots/connection-colors.png b/docs/_static/screenshots/connection-colors.png new file mode 100755 index 000000000..9b9a174ad Binary files /dev/null and b/docs/_static/screenshots/connection-colors.png differ diff --git a/docs/_static/screenshots/custom-painter.png b/docs/_static/screenshots/custom-painter.png new file mode 100755 index 000000000..4d4e5a3e5 Binary files /dev/null and b/docs/_static/screenshots/custom-painter.png differ diff --git a/docs/_static/screenshots/dynamic-ports.png b/docs/_static/screenshots/dynamic-ports.png new file mode 100755 index 000000000..904f7aa07 Binary files /dev/null and b/docs/_static/screenshots/dynamic-ports.png differ diff --git a/docs/_static/screenshots/embedded-widget.png b/docs/_static/screenshots/embedded-widget.png new file mode 100755 index 000000000..f96f5b70b Binary files /dev/null and b/docs/_static/screenshots/embedded-widget.png differ diff --git a/docs/_static/flow.png b/docs/_static/screenshots/flow.png similarity index 100% rename from docs/_static/flow.png rename to docs/_static/screenshots/flow.png diff --git a/docs/_static/screenshots/locked-node.png b/docs/_static/screenshots/locked-node.png new file mode 100755 index 000000000..901b90426 Binary files /dev/null and b/docs/_static/screenshots/locked-node.png differ diff --git a/docs/_static/screenshots/node-anatomy.png b/docs/_static/screenshots/node-anatomy.png new file mode 100644 index 000000000..3d72f7947 Binary files /dev/null and b/docs/_static/screenshots/node-anatomy.png differ diff --git a/docs/_static/screenshots/processing-status.png b/docs/_static/screenshots/processing-status.png new file mode 100755 index 000000000..e8ac3ba73 Binary files /dev/null and b/docs/_static/screenshots/processing-status.png differ diff --git a/docs/_static/screenshots/quickstart-connect.png b/docs/_static/screenshots/quickstart-connect.png new file mode 100644 index 000000000..814b4b779 Binary files /dev/null and b/docs/_static/screenshots/quickstart-connect.png differ diff --git a/docs/_static/screenshots/quickstart-create.png b/docs/_static/screenshots/quickstart-create.png new file mode 100644 index 000000000..74506e237 Binary files /dev/null and b/docs/_static/screenshots/quickstart-create.png differ diff --git a/docs/_static/screenshots/quickstart-result.png b/docs/_static/screenshots/quickstart-result.png new file mode 100644 index 000000000..6508bc777 Binary files /dev/null and b/docs/_static/screenshots/quickstart-result.png differ diff --git a/docs/_static/screenshots/quickstart-select.png b/docs/_static/screenshots/quickstart-select.png new file mode 100644 index 000000000..4167e5d29 Binary files /dev/null and b/docs/_static/screenshots/quickstart-select.png differ diff --git a/docs/_static/screenshots/resizable.png b/docs/_static/screenshots/resizable.png new file mode 100755 index 000000000..effaebdbd Binary files /dev/null and b/docs/_static/screenshots/resizable.png differ diff --git a/docs/_static/style_example.png b/docs/_static/screenshots/style-example.png similarity index 100% rename from docs/_static/style_example.png rename to docs/_static/screenshots/style-example.png diff --git a/docs/_static/screenshots/text.png b/docs/_static/screenshots/text.png new file mode 100755 index 000000000..c6b2870d8 Binary files /dev/null and b/docs/_static/screenshots/text.png differ diff --git a/docs/_static/screenshots/validation.png b/docs/_static/screenshots/validation.png new file mode 100755 index 000000000..1a33759f3 Binary files /dev/null and b/docs/_static/screenshots/validation.png differ diff --git a/docs/_static/screenshots/vertical-layout.png b/docs/_static/screenshots/vertical-layout.png new file mode 100755 index 000000000..b4f45a14c Binary files /dev/null and b/docs/_static/screenshots/vertical-layout.png differ diff --git a/docs/classes.rst b/docs/api/classes.rst similarity index 65% rename from docs/classes.rst rename to docs/api/classes.rst index 6b074fcaf..b443058b6 100644 --- a/docs/classes.rst +++ b/docs/api/classes.rst @@ -1,32 +1,47 @@ QtNodes Class Reference ======================= -Basic Classes -------------- +This page provides auto-generated API documentation from the source code. + +Core Classes +------------ + +Graph Model +^^^^^^^^^^^ .. doxygenclass:: QtNodes::AbstractGraphModel :members: -.. doxygenstruct:: QtNodes::NodeDataType +.. doxygenclass:: QtNodes::DataFlowGraphModel :members: -.. doxygenclass:: QtNodes::NodeData - :members: +Scene and View +^^^^^^^^^^^^^^ -.. doxygenstruct:: QtNodes::ConnectionId +.. doxygenclass:: QtNodes::BasicGraphicsScene :members: -.. doxygenclass:: QtNodes::BasicGraphicsScene +.. doxygenclass:: QtNodes::DataFlowGraphicsScene :members: .. doxygenclass:: QtNodes::GraphicsView :members: -.. doxygenclass:: QtNodes::GraphicsViewStyle - :members: +Node Classes +------------ + +Graphics +^^^^^^^^ .. doxygenclass:: QtNodes::NodeGraphicsObject :members: + :no-link: + +.. doxygenclass:: QtNodes::NodeState + :members: + +Painting +^^^^^^^^ .. doxygenclass:: QtNodes::AbstractNodePainter :members: @@ -34,6 +49,9 @@ Basic Classes .. doxygenclass:: QtNodes::DefaultNodePainter :members: +Geometry +^^^^^^^^ + .. doxygenclass:: QtNodes::AbstractNodeGeometry :members: @@ -43,70 +61,111 @@ Basic Classes .. doxygenclass:: QtNodes::DefaultVerticalNodeGeometry :members: -.. doxygenclass:: QtNodes::NodeState +Connection Classes +------------------ + +.. doxygenclass:: QtNodes::ConnectionGraphicsObject :members: + :no-link: -.. doxygenclass:: QtNodes::NodeStyle +.. doxygenclass:: QtNodes::AbstractConnectionPainter :members: -.. doxygenclass:: QtNodes::ConnectionGraphicsObject +.. doxygenclass:: QtNodes::DefaultConnectionPainter + :members: + +.. doxygenclass:: QtNodes::NodeConnectionInteraction + :members: + +Data Flow Classes +----------------- + +.. doxygenclass:: QtNodes::NodeDelegateModel + :members: + +.. doxygenclass:: QtNodes::NodeDelegateModelRegistry + :members: + +.. doxygenclass:: QtNodes::NodeData :members: -.. doxygenclass:: QtNodes::ConnectionPainter +Styling +------- + +.. doxygenclass:: QtNodes::NodeStyle :members: .. doxygenclass:: QtNodes::ConnectionStyle :members: -.. doxygenclass:: QtNodes::NodeConnectionInteraction +.. doxygenclass:: QtNodes::GraphicsViewStyle + :members: + +.. doxygenclass:: QtNodes::StyleCollection :members: -Undo Redo ---------- +Undo Commands +------------- + +.. doxygenclass:: QtNodes::CreateCommand + :members: .. doxygenclass:: QtNodes::DeleteCommand :members: -.. doxygenclass:: QtNodes::DuplicateCommand +.. doxygenclass:: QtNodes::ConnectCommand :members: .. doxygenclass:: QtNodes::DisconnectCommand :members: -.. doxygenclass:: QtNodes::ConnectCommand +.. doxygenclass:: QtNodes::MoveNodeCommand :members: -.. doxygenclass:: QtNodes::MoveNodeCommand +.. doxygenclass:: QtNodes::CopyCommand :members: -Dataflow Classes ----------------- +.. doxygenclass:: QtNodes::PasteCommand + :members: -.. doxygenclass:: QtNodes::DataFlowGraphicsScene +Data Types +---------- + +Structs +^^^^^^^ + +.. doxygenstruct:: QtNodes::NodeDataType :members: -.. doxygenclass:: QtNodes::DataFlowGraphModel +.. doxygenstruct:: QtNodes::ConnectionId :members: -.. doxygenclass:: QtNodes::NodeDelegateModel +.. doxygenstruct:: QtNodes::NodeValidationState :members: -.. doxygenclass:: QtNodes::NodeDelegateModelRegistry +.. doxygenstruct:: QtNodes::ProcessingIconStyle :members: -Definitions ------------ +Type Definitions +^^^^^^^^^^^^^^^^ .. doxygentypedef:: QtNodes::NodeId .. doxygentypedef:: QtNodes::PortIndex +Enumerations +^^^^^^^^^^^^ + .. doxygenenum:: QtNodes::NodeRole .. doxygenenum:: QtNodes::NodeFlag .. doxygenenum:: QtNodes::PortRole +.. doxygenenum:: QtNodes::PortType + .. doxygenenum:: QtNodes::ConnectionPolicy -.. doxygenenum:: QtNodes::PortType +.. doxygenenum:: QtNodes::NodeProcessingStatus + +.. doxygenenum:: QtNodes::ProcessingIconPos diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 000000000..9c041d0aa --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,146 @@ +API Overview +============ + +This section provides API reference documentation for QtNodes. + +Quick Reference +--------------- + +**Core Classes** + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Class + - Purpose + * - ``AbstractGraphModel`` + - Base class for all graph models + * - ``DataFlowGraphModel`` + - Model with automatic data propagation + * - ``BasicGraphicsScene`` + - Visualizes any graph model + * - ``DataFlowGraphicsScene`` + - Scene for data flow models (with menus) + * - ``GraphicsView`` + - View widget with interactions + * - ``NodeDelegateModel`` + - Base class for node logic in data flow + +**Graphics Classes** + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Class + - Purpose + * - ``NodeGraphicsObject`` + - Visual representation of a node + * - ``ConnectionGraphicsObject`` + - Visual representation of a connection + * - ``AbstractNodePainter`` + - Interface for custom node rendering + * - ``AbstractConnectionPainter`` + - Interface for custom connection rendering + * - ``AbstractNodeGeometry`` + - Interface for custom node layout + +**Style Classes** + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Class + - Purpose + * - ``StyleCollection`` + - Global style management + * - ``NodeStyle`` + - Node appearance settings + * - ``ConnectionStyle`` + - Connection appearance settings + * - ``GraphicsViewStyle`` + - Canvas appearance settings + +**Data Types** + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Type + - Purpose + * - ``NodeId`` + - ``unsigned int`` - unique node identifier + * - ``PortIndex`` + - ``unsigned int`` - port number (0-based) + * - ``ConnectionId`` + - Struct identifying a connection + * - ``NodeRole`` + - Enum for node data queries + * - ``PortRole`` + - Enum for port data queries + * - ``PortType`` + - ``In``, ``Out``, or ``None`` + * - ``ConnectionPolicy`` + - ``One`` or ``Many`` connections + +**Enums** + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Enum + - Values + * - ``NodeRole`` + - Type, Position, Size, Caption, CaptionVisible, Style, InternalData, + InPortCount, OutPortCount, Widget, ValidationState, ProcessingStatus + * - ``PortRole`` + - Data, DataType, ConnectionPolicyRole, CaptionVisible, Caption + * - ``NodeFlag`` + - NoFlags, Resizable, Locked + * - ``NodeValidationState::State`` + - Valid, Warning, Error + * - ``NodeProcessingStatus`` + - NoStatus, Updated, Processing, Pending, Empty, Failed, Partial + +Header Files +------------ + +All public headers are in ``QtNodes/``: + +.. code-block:: cpp + + // Core + #include + #include + #include + #include + #include + + // Graphics + #include + #include + #include + + // Styling + #include + #include + #include + #include + + // Utilities + #include + #include + +Full API Reference +------------------ + +See :doc:`classes` for complete Doxygen-generated API documentation. + +.. toctree:: + :hidden: + + classes diff --git a/docs/conf.py b/docs/conf.py index 622f720cd..ff97b881a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,6 +21,11 @@ def configureDoxyfile(input_dir, output_dir): configureDoxyfile(input_dir, output_dir) subprocess.call('doxygen', shell=True) breathe_projects['QtNodes'] = output_dir + '/xml/' +else: + # Local build: use CMake-generated Doxygen XML + build_xml_path = '../build/docs/doxygen/xml/' + if os.path.exists(build_xml_path): + breathe_projects['QtNodes'] = build_xml_path # -- Project information ----------------------------------------------------- @@ -51,7 +56,7 @@ def configureDoxyfile(input_dir, output_dir): # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', 'venv'] # -- Options for HTML output ------------------------------------------------- diff --git a/docs/development.rst b/docs/development.rst deleted file mode 100644 index ae56ab79e..000000000 --- a/docs/development.rst +++ /dev/null @@ -1,17 +0,0 @@ -Development Progress -==================== - - -- [✅ done] Save/restore to Json. Maybe inherit the GraphModel from Serializable -- [✅ done] Vertical layout -- [✅ done] Dynamic ports -- [✅ done] ``AbstractNodeGeometry``, ``AbstractNodePainter`` -- [✅ done] Website with documentation -- [✅ done] ``ConnectionPaintDelegate`` -- [✅ done] Unit-Tests -- [✅ done] Ctrl+D for copying and inserting a selection duplicate -- [⏸ not started] Node groups -- [⏸ not started] Check how styles work and what needs to be done. See old pull-requests -- [☝ help needed] Python bindings. Maybe a wrapper using Shiboken - - Python examples -- [☝ help needed] QML front-end diff --git a/docs/examples/index.rst b/docs/examples/index.rst new file mode 100644 index 000000000..b0733883a --- /dev/null +++ b/docs/examples/index.rst @@ -0,0 +1,142 @@ +Examples +======== + +Complete working examples demonstrating QtNodes features. + +.. tip:: + + All examples are in the ``examples/`` directory. Build with + ``-DBUILD_EXAMPLES=ON`` (default). + +Gallery +------- + +.. list-table:: + :widths: 30 70 + :header-rows: 0 + + * - .. image:: /_static/screenshots/calculator.png + :width: 250px + - **Calculator** (``examples/calculator/``) + + Full data flow application with number sources, operators, and display. + Demonstrates embedded widgets, save/load, and menu integration. + + :doc:`/guide/data-flow` + + * - .. image:: /_static/screenshots/flow.png + :width: 250px + - **Simple Graph Model** (``examples/simple_graph_model/``) + + Minimal custom graph model implementation. Shows how to subclass + ``AbstractGraphModel`` and use ``BasicGraphicsScene``. + + :doc:`/guide/graph-models` + + * - .. image:: /_static/screenshots/style-example.png + :width: 250px + - **Styles** (``examples/styles/``) + + Custom styling with different color schemes and visual effects. + + :doc:`/guide/styling` + + * - .. image:: /_static/screenshots/connection-colors.png + :width: 250px + - **Connection Colors** (``examples/connection_colors/``) + + Data-type-based connection coloring. Each data type has its own color. + + :doc:`/guide/styling` + + * - .. image:: /_static/screenshots/vertical-layout.png + :width: 250px + - **Vertical Layout** (``examples/vertical_layout/``) + + Top-to-bottom node arrangement with ports on top and bottom edges. + + :doc:`/guide/visualization` + + * - .. image:: /_static/screenshots/dynamic-ports.png + :width: 250px + - **Dynamic Ports** (``examples/dynamic_ports/``) + + Add and remove ports at runtime. Shows the two-phase port modification API. + + :doc:`/guide/advanced` + + * - .. image:: /_static/screenshots/resizable.png + :width: 250px + - **Resizable Images** (``examples/resizable_images/``) + + Nodes with embedded image widgets that can be resized by dragging. + + :doc:`/guide/advanced` + + * - .. image:: /_static/screenshots/locked-node.png + :width: 250px + - **Lock Nodes & Connections** (``examples/lock_nodes_and_connections/``) + + Prevent node movement and connection detachment. + + :doc:`/guide/advanced` + + * - .. image:: /_static/screenshots/validation.png + :width: 250px + - **Node Validation** (``examples/node_validation/``) + + Demonstrates ``NodeValidationState`` and ``NodeProcessingStatus``. + Shows error/warning states and processing indicators. + + :doc:`/guide/data-flow` + + * - .. image:: /_static/screenshots/custom-painter.png + :width: 250px + - **Custom Painter** (``examples/custom_painter/``) + + Custom node and connection rendering with gradients and arrows. + + :doc:`/guide/visualization` + + * - .. image:: /_static/screenshots/text.png + :width: 250px + - **Text** (``examples/text/``) + + Simple text propagation between nodes. Good starting point. + + :doc:`/guide/data-flow` + +Headless Example +---------------- + +``examples/calculator/headless_main.cpp`` demonstrates running computations +without any GUI: + +.. code-block:: cpp + + // Load a saved graph + DataFlowGraphModel model(registry); + model.load(loadJsonFromFile("saved_graph.json")); + + // Modify inputs programmatically + auto* sourceNode = model.delegateModel(sourceNodeId); + sourceNode->setValue(42.0); + + // Read computed output + auto* displayNode = model.delegateModel(displayNodeId); + double result = displayNode->getValue(); + +Running Examples +---------------- + +After building: + +.. code-block:: bash + + # From build directory + ./bin/calculator + ./bin/simple_graph_model + ./bin/styles + # ... etc + +Each example is a standalone executable demonstrating specific features. diff --git a/docs/faq.rst b/docs/faq.rst new file mode 100644 index 000000000..d1f92a6a2 --- /dev/null +++ b/docs/faq.rst @@ -0,0 +1,203 @@ +FAQ & Troubleshooting +===================== + +Frequently asked questions and common issues. + +General Questions +----------------- + +**Which model should I use?** + +- Use ``DataFlowGraphModel`` + ``NodeDelegateModel`` if you want automatic + data propagation between nodes (visual programming, calculators, pipelines). + +- Use a custom ``AbstractGraphModel`` subclass if you have existing graph + data structures or need custom graph semantics. + +**Can I use QtNodes without a GUI?** + +Yes. The model classes (``AbstractGraphModel``, ``DataFlowGraphModel``) work +without any scene or view. This is called "headless mode." + +.. code-block:: cpp + + DataFlowGraphModel model(registry); + NodeId n1 = model.addNode("Source"); + NodeId n2 = model.addNode("Display"); + model.addConnection({n1, 0, n2, 0}); + // Data flows, no GUI needed + +**Does QtNodes support Qt5 and Qt6?** + +Yes. Set ``-DUSE_QT6=OFF`` for Qt5, or ``-DUSE_QT6=ON`` (default) for Qt6. + +Common Issues +------------- + +**Nodes don't appear after adding them** + +Make sure you emit the ``nodeCreated`` signal: + +.. code-block:: cpp + + NodeId MyModel::addNode(QString type) override + { + NodeId id = newNodeId(); + _nodes.insert(id); + emit nodeCreated(id); // Don't forget this! + return id; + } + +**Connections disappear or don't work** + +Check that: + +1. ``connectionPossible()`` returns ``true`` for valid connections +2. ``connectionExists()`` correctly checks your data structure +3. You emit ``connectionCreated`` after adding a connection + +**Undo doesn't restore deleted nodes** + +Implement ``saveNode()`` and ``loadNode()`` in your model: + +.. code-block:: cpp + + QJsonObject MyModel::saveNode(NodeId nodeId) const override + { + // Return all data needed to recreate this node + } + + void MyModel::loadNode(QJsonObject const& json) override + { + // Recreate node from saved data + } + +**Embedded widget doesn't show** + +Ensure your ``embeddedWidget()`` returns a valid widget: + +.. code-block:: cpp + + QWidget* MyNode::embeddedWidget() override + { + if (!_widget) { + _widget = new QLineEdit(); // Create lazily + } + return _widget; + } + +**Data doesn't propagate to connected nodes** + +Make sure you emit ``dataUpdated`` when output changes: + +.. code-block:: cpp + + void MyNode::compute() + { + _result = doComputation(); + emit dataUpdated(0); // Notify downstream nodes + } + +**Crash when deleting nodes** + +Delete connections before deleting the node: + +.. code-block:: cpp + + bool MyModel::deleteNode(NodeId nodeId) override + { + // Remove connections first + for (auto& conn : allConnectionIds(nodeId)) { + deleteConnection(conn); + } + + _nodes.erase(nodeId); + emit nodeDeleted(nodeId); + return true; + } + +Build Issues +------------ + +**Catch2 not found** + +Either install Catch2 or disable testing: + +.. code-block:: bash + + cmake .. -DBUILD_TESTING=OFF + +**Qt version conflicts** + +Make sure all components use the same Qt version. Check with: + +.. code-block:: bash + + cmake .. -DUSE_QT6=ON # or OFF for Qt5 + +**Linking errors on Windows** + +Ensure you're using the correct build type: + +.. code-block:: bash + + cmake .. -DCMAKE_BUILD_TYPE=Release + cmake --build . --config Release + +Performance Questions +--------------------- + +**How many nodes can QtNodes handle?** + +QtNodes has been used with hundreds of nodes. For very large graphs (1000+), +consider: + +- Using ``BasicGraphicsScene`` instead of ``DataFlowGraphicsScene`` +- Implementing virtualization (only create graphics for visible nodes) +- Disabling shadows and complex styles + +**Why is my graph slow to render?** + +Check for: + +- Complex embedded widgets in every node +- Heavy computation in ``paint()`` methods +- Many connections causing recalculation + +Feature Questions +----------------- + +**Can I have ports on all four sides?** + +The built-in geometries support horizontal (left/right) or vertical (top/bottom). +For ports on all sides, create a custom ``AbstractNodeGeometry``. + +**Can I group nodes?** + +Node grouping is planned but not yet implemented. Track progress on GitHub. + +**Can I have curved connections?** + +Yes, the default connection painter draws cubic bezier curves. For custom +curves, create a custom ``AbstractConnectionPainter``. + +**Can I add custom context menus?** + +Override ``createSceneMenu()`` in your scene subclass: + +.. code-block:: cpp + + QMenu* MyScene::createSceneMenu(QPointF scenePos) override + { + auto* menu = new QMenu(); + menu->addAction("Custom Action", [=]() { ... }); + return menu; + } + +Getting Help +------------ + +- **Documentation**: You're reading it! +- **Examples**: Check ``examples/`` for working code +- **Issues**: `GitHub Issues `_ +- **Discussions**: `GitHub Discussions `_ diff --git a/docs/features.rst b/docs/features.rst deleted file mode 100644 index 9f379cbdd..000000000 --- a/docs/features.rst +++ /dev/null @@ -1,396 +0,0 @@ -Feature Overview -================ - - -Graph object IDs ----------------- - -Nodes and Connections do not have any dedicated data instances. Instead, all the -relevant data is stored inside a user-defined ``GraphModel`` class inherited from -``AbstractGraphModel``. - -:: - - // Definitions.hpp - using NodeId = unsigned int; - -Each node is associated with a **unique** integer ``NodeId`` which is returned -from the function ``NodeId AbstractGraphModel::addNode(QString)``. - -Important - This is the responsibility of the model to generate unique ``NodeId`` s. - - -The ``ConnectionId`` is nothing else but a combination of input and output -``NodeId`` values with the corresponding ``PortIndex``: - -:: - - // Definitinos.hpp - struct ConnectionId - { - NodeId outNodeId; - PortIndex outPortIndex; - NodeId inNodeId; - PortIndex inPortIndex; - }; - -Serialization -------------- - -The serialization is supported at the moment by ``DataFlowGraphModel``. Should -you implement your own derivative of the class ``AbstractGraphModel``, it's up to -you to support the serialization using the existing helper functions. An example -of such a code could be found in ``src/DataFlowGraphModel.cpp``. - -In order so save the whole scene we normally make a loop over all the nodes and -save their data in a Json arrray with a key "nodes" and then over all the -connections which are saved in an array "connections". - -A typical node Json saved for the data flow scenario looks as follows: - -:: - - { - "id" : 0, - "internal-data" : { - "model-name" : "Subtraction" - }, - "position" : { - "x" : -383, - "y" : -95 - } - } - -The section ``internal-data`` is filled by the specific data of the node itself -and is created in the function ``DataFlowGraphModel::saveNode(NodeId)``. In the -data-propagation workflow we store a name of the model there. In your application -it could be any internal additional data, i.e. an internal node state. - -The Json for a serialized Connection in this case looks very simple: - -:: - - { - "inPortIndex" : 0, - "intNodeId" : 1, - "outNodeId" : 0, - "outPortIndex" : 0 - } - -The data above is produced by a function ``DataFlowGraphModel::saveConnection``. - -Code Example - See the function ``DataFlowGraphModel::save()`` in the file - ``src/DataFlowGraphModel.cpp``. - - -Undo/Redo ---------- - -In order to support the undo/redo capabilities we employ the standar Qt's class -`QUndoStack `_ . We keep a stack instance inside your ``BasicGraphicsScene`` (or its derivatives). - -Some default ``QUndoCommand`` s are already implemented in the file -``src/QUndoCommands.cpp`` - -The command ``DeleteCommand`` uses serialization to store the information of the -removed objects, namely ``AbstractGraphModel::saveNode(NodeId)`` and -``AbstractGraphModel::saveConnection(ConnectionId)``. Make sure you override -these functions in your derived graph models. - -Wrapping your Graph Structure ------------------------------ - -If your task is to visualize a graph specific to your application and you do not -need the basic "data propagation" semantic implemented in this library -(``DataFlowGraphModel``, ``DataFlowGraphicsScene``), you might need to derive -from ``AbstractGraphModel`` and implement several abstract functions. - -The models follows pretty much the ideas underlying the Qt's -``QAbstractItemModel``. The class delivers the IDs for all the existing scene -objects, the model is responsible for generating such unique IDs. You should -deliver information about nodes via ``AbstractGraphModel::nodeData`` and assign -the data to nodes using ``AbstractGraphModel::setNodeData``. The passed and -returned data is wrapped into the data type ``QVariant`` - -The pivotal ``enum`` that defines type of the information we need to obtain is -called ``NodeRole``. See the file ``include/QtNodes/internal/Definitions.hpp``. - - -.. table:: - :widths: 10 30 - - ==================== ========================== - NodeRole Description - ==================== ========================== - Type It corresponds to the type of the node and is described - by a ``QString`` value. For example, in the data-flow - models we return the name of the ``NodeDelegateModel`` - that needs to be instantiated by - ``NodeDelegateModelRegistry``. - - Position ``QPointF`` position of the node on the scene. - - Size ``QSize`` the size of the internal area of thet node. - Typically it is used by embedding a widged inside a - node. - - CaptionVisible ``bool`` that defines whether to show a node's caption. - - Caption ``QString`` defines whether to show a node's caption. - - Style Node editor's internal json structure returned as a - ``QVariantMap`` that defines colors, gradients and - effects for the node painting - - InternalData ``QJsonObject`` converted to ``QVariantMap`` that - serializes the iternal node's state. - - InPortCount ``unsigned int`` -- the number of input ports. - - OutPortCount ``unsigned int`` -- the number of output ports. - - Widget ``QWidget*`` a pointer to allocated QWidget instance - that must be embedded into a node. If nothing is - embedded, it must be ``nullptr`` by default. - ==================== ========================== - -Code Example - For the usage see ``examples/simple_graph_model``. - - -Node and Scene Styling ----------------------- - -Default Node, Connection, and GraphicsView styles are stored in a centrall class -``StyleCollection``. - -Each default style is parsed from an internal Json string and stored in a -corresponding data-class. Below you'll find the contents of the Json strings at -the moment of writing this documentation. - -**GraphicsViewStyle** - -:: - - { - "GraphicsViewStyle": { - "BackgroundColor": [53, 53, 53], - "FineGridColor": [60, 60, 60], - "CoarseGridColor": [25, 25, 25] - } - } - - -**NodeStyle** - -:: - - { - "NodeStyle": { - "NormalBoundaryColor": [255, 255, 255], - "SelectedBoundaryColor": [255, 165, 0], - "GradientColor0": "gray", - "GradientColor1": [80, 80, 80], - "GradientColor2": [64, 64, 64], - "GradientColor3": [58, 58, 58], - "ShadowColor": [20, 20, 20], - "ShadowEnabled": false, - "FontColor" : "white", - "FontColorFaded" : "gray", - "ConnectionPointColor": [169, 169, 169], - "FilledConnectionPointColor": "cyan", - "ErrorColor": "red", - "WarningColor": [128, 128, 0], - - "PenWidth": 1.0, - "HoveredPenWidth": 1.5, - - "ConnectionPointDiameter": 8.0, - - "Opacity": 0.8 - } - } - - -**ConnectionStyle** - -:: - - { - "ConnectionStyle": { - "ConstructionColor": "gray", - "NormalColor": "darkcyan", - "SelectedColor": [100, 100, 100], - "SelectedHaloColor": "orange", - "HoveredColor": "lightcyan", - - "LineWidth": 3.0, - "ConstructionLineWidth": 2.0, - "PointDiameter": 10.0, - - "UseDataDefinedColors": false - } - } - -Code Example - For the usage see ``examples/styles`` and ``examples/connection_colors``. - -Vertical Layout ---------------- - -This feature might seem to be a bit "raw". I haven't had good use cases from real -life projects to polish the code and the resulting node layout and rendering. - -The current node layout in a vertical mode looks as follows: - -:: - - -------o-------------o------- - | PortCaption PortCaption | - | | - | Node Caption | - | | - | | - | PortCaption | - --------------o-------------- - -Code Example - For the usage see ``examples/vertical_layout``. - - -Dynamic Ports -------------- - -Dynamic Ports operations are driven by functions of the class -``AbstractGraphModel``: - -- ``AbstractGraphModel::portsAboutToBeDeleted`` -- ``AbstractGraphModel::portsDeleted`` -- ``AbstractGraphModel::portsAboutToBeInserted`` -- ``AbstractGraphModel::portsInserted`` - - -The function with the name "AboutTo" prepares the changes: - -1. It computes the new connection IDs that are to be applied after the change is - done. -2. It removes the existing connections that would have invalid addresses after - modifications. - -The functions ``porstDeleted`` and ``portsInserted`` create the new precomputed -connections with the correct IDs. - -If you want to modify the number of ports in your code, it should approximately -as follows: - -:: - - void YourGraphModel::addPort() - { - portsAboutToBeInserted(nodeId, PortType::Out, 1, 2); - - // DO YOUR UNDERLYING DATA MODIFICATIONS HERE - // The function call above has prepared the insertion of new output ports - // with the indexes 1 and 2. - // All the existed connectes below the new port 2 would be deleted and - // re-inserted with the new IDs (shifted by 2). - - porstInserted(); - } - - -Code Example - For the usage see ``examples/dynamic_ports``. - - -Locked Nodes and Connections ----------------------------- - -It is possible to completely disable or "freeze" the nodes. This would make them -insensitive to moving and selecting events with the mouse. - -In order to achieve such a behavior set specific flags and return from your graph -model: - -:: - - - NodeFlags - YourGraphModel:: - nodeFlags(NodeId nodeId) const override - { - auto basicFlags = DataFlowGraphModel::nodeFlags(nodeId); - - if (_nodesLocked) - basicFlags |= NodeFlag::Locked; - - return basicFlags; - } - - - -Disabled Connection Detaching ------------------------------ - - -For disabling detaching the connections from certain nodes override the function -``virtual bool AbstractGraphModel::detachPossible(ConnectionId const) const``. -The default implementaion always returns ``true``. - -Code Example - For the usage see ``examples/lock_nodes_and_connections``. - - - -Data Propagation ----------------- - -Data-propagating classes add extra funtionality to the basic -``AbstractGraphModel`` which allows them to push the data from node to node upon -creating a connection. - -The chain starts from the instance of a ``NodeDelegateModel``. It emits a Qt -signal ``dataUpdated(PortIndex)``. We always assume that the data is emitted from -one of the right hand side ports (``PortType::Out``). - -Then the function ``DataFlowGraphModel::onOutPortDataUpdated(NodeId, PortIndex)`` -comes into play. It reads the data from the output port, collects all the -attached connections for the given ``PortIndex`` and sets the data to the -connected nodes using ``DataFlowGraphModel::setPortData``. After setting the data -to the input delegate model via ``NodeDelegateModel::setInData(...)`` we emit the -signal ``inPortDataWasSet(nodeId, portType, portIndex)``. The signal is used to -redraw the receiver node and could be hooked up for other user's purposes. - -:: - - NodeDelegateModel:::dataUpdated(PortIndex) - - // Source Delegate Model -> source NodeId - DataFlowGraphModel::onOutPortDataUpdated(NodeId, PortIndex) - - // soure NodeId -> target NodeId - DataFlowGraphModel::setPortData() - - // target NodeId -> target Delegate Model - NodeDelegateModel::setInData(NodeData, portIndex) - - DataFlowGraphModel::setPortData() - - -Headless Mode -^^^^^^^^^^^^^ - -The class ``AbstractGraphModel`` is independent of any scenes or visualization -windows. It is possible to instantiate a descendant of this abstract class and -populate the graph model. - -Any instantiated ``BasicGraphicsScene`` could be also used without attaching it -to a dedicated ``GraphicsView``. - -Code Example - See ``examples/calculator/headless_main.cpp``. In this file we instantiate just - a ``DataFlowGraphModel`` and load a pre-saved calculator graph structure into - it. The model is able to compute the results if the user modifies the inputs in - the code. diff --git a/docs/getting-started/concepts.rst b/docs/getting-started/concepts.rst new file mode 100644 index 000000000..cfd7691df --- /dev/null +++ b/docs/getting-started/concepts.rst @@ -0,0 +1,193 @@ +Core Concepts +============= + +Understanding QtNodes' architecture helps you make the right design decisions +for your application. + +Model-View Architecture +----------------------- + +QtNodes follows Qt's Model-View pattern. Your data (the **Model**) is separate +from its visualization (the **View**). + +.. image:: /_static/diagrams/architecture-diagram.png + :alt: Model-View architecture diagram + :align: center + :width: 600px + +.. + SCREENSHOT NEEDED: architecture-diagram.png + Create a simple diagram showing: + + +------------------+ +----------------------+ + | AbstractGraph | ------> | BasicGraphicsScene | + | Model | | (QGraphicsScene) | + +------------------+ +----------------------+ + ^ | + | v + Your Data +----------------------+ + (nodes, connections, | GraphicsView | + positions, etc.) | (QGraphicsView) | + +----------------------+ + + Use boxes and arrows. Can be created in any diagramming tool. + Size: ~600px wide + +**Benefits of this separation:** + +- Run graph logic without any GUI (headless mode) +- Multiple views of the same data +- Clear ownership: you own your data, the library owns the visuals +- Easy testing of graph logic + +Nodes and Connections +--------------------- + +Graphs consist of **nodes** and **connections**. + +.. image:: /_static/screenshots/node-anatomy.png + :alt: Anatomy of a node + :align: center + :width: 400px + +**Node identification:** + +- Each node has a unique ``NodeId`` (unsigned integer) +- You generate IDs in your model via ``newNodeId()`` +- The library never creates or manages IDs for you + +**Connections:** + +- A ``ConnectionId`` links an output port to an input port +- Contains: ``outNodeId``, ``outPortIndex``, ``inNodeId``, ``inPortIndex`` +- Connections are directional: data flows from Out to In + +.. code-block:: cpp + + // A connection from Node 1, Port 0 -> Node 2, Port 0 + ConnectionId conn{ + .outNodeId = 1, + .outPortIndex = 0, + .inNodeId = 2, + .inPortIndex = 0 + }; + +The Two Programming Models +-------------------------- + +QtNodes supports two distinct approaches: + +.. list-table:: + :widths: 50 50 + :header-rows: 1 + + * - **Simple Graph Model** + - **Data Flow Model** + * - You subclass ``AbstractGraphModel`` + - You use ``DataFlowGraphModel`` + delegates + * - You store all graph data yourself + - Library manages node instances for you + * - No automatic data propagation + - Data flows automatically through connections + * - Full control, more code + - Less code, follows conventions + * - ``BasicGraphicsScene`` + - ``DataFlowGraphicsScene`` + +**Choose Simple Graph Model when:** + +- You have existing graph data structures to wrap +- You need custom graph semantics +- You don't need data to flow between nodes + +**Choose Data Flow Model when:** + +- Building visual programming tools +- Nodes process inputs and produce outputs +- You want automatic data propagation + +NodeRole and PortRole +--------------------- + +The model provides data about nodes and ports through **roles** (similar to +Qt's ``ItemDataRole``). + +**NodeRole** -- Information about a node: + +.. code-block:: cpp + + // Get node position + QPointF pos = model.nodeData(nodeId, NodeRole::Position).toPointF(); + + // Set node caption + model.setNodeData(nodeId, NodeRole::Caption, "My Node"); + +Key roles: ``Type``, ``Position``, ``Caption``, ``InPortCount``, ``OutPortCount``, ``Widget`` + +**PortRole** -- Information about a port: + +.. code-block:: cpp + + // Get port data type + auto type = model.portData(nodeId, PortType::In, 0, PortRole::DataType); + +Key roles: ``Data``, ``DataType``, ``Caption``, ``ConnectionPolicyRole`` + +See :doc:`/guide/graph-models` for the complete role reference. + +Graphics Object Hierarchy +------------------------- + +When visualized, each model element becomes a graphics object: + +.. code-block:: text + + GraphicsView (QGraphicsView) + └── BasicGraphicsScene (QGraphicsScene) + ├── NodeGraphicsObject (for each node) + │ └── Embedded QWidget (optional) + └── ConnectionGraphicsObject (for each connection) + +**Painters** control how objects are drawn: + +- ``AbstractNodePainter`` → ``DefaultNodePainter`` +- ``AbstractConnectionPainter`` → ``DefaultConnectionPainter`` + +You can replace these with custom painters for different visual styles. + +Data Flow Deep Dive +------------------- + +In the data flow model, delegates handle node-specific logic: + +.. image:: /_static/diagrams/dataflow-diagram.png + :alt: Data flow between nodes + :align: center + :width: 500px + +.. + SCREENSHOT NEEDED: dataflow-diagram.png + Show data flowing through connected nodes: + + [Source Node] --> [Operator Node] --> [Display Node] + | | | + "emits data" "receives, processes, "receives, + emits result" displays" + + Use arrows showing dataUpdated signal flow. + Size: ~500px wide + +**The flow:** + +1. Source node calls ``emit dataUpdated(portIndex)`` +2. ``DataFlowGraphModel`` catches this signal +3. It calls ``outData(port)`` on the source delegate +4. It calls ``setInData(data, port)`` on all connected delegates +5. Connected nodes process and may emit their own ``dataUpdated`` + +Next Steps +---------- + +- :doc:`/guide/graph-models` -- Implement your own graph model +- :doc:`/guide/data-flow` -- Use the data flow pattern +- :doc:`/examples/index` -- Study working examples diff --git a/docs/getting-started/installation.rst b/docs/getting-started/installation.rst new file mode 100644 index 000000000..1a9e6c3ad --- /dev/null +++ b/docs/getting-started/installation.rst @@ -0,0 +1,128 @@ +Installation +============ + +Requirements +------------ + +- **Qt** 5.15+ or Qt 6.x +- **Qt SVG module** (for processing status icons) +- **CMake** 3.11+ +- **C++ Compiler** with C++17 support (GCC 7+, Clang 5+, MSVC 2019+) +- **Catch2** (optional, for running tests) + +On Ubuntu/Debian, install Qt SVG with: + +.. code-block:: bash + + # For Qt6 + sudo apt install libqt6svg6 + + # For Qt5 + sudo apt install libqt5svg5 + +Building from Source +-------------------- + +**1. Clone the repository** + +.. code-block:: bash + + git clone https://github.com/paceholder/nodeeditor.git + cd nodeeditor + +**2. Create build directory and configure** + +.. code-block:: bash + + mkdir build && cd build + cmake .. + +**3. Build** + +.. code-block:: bash + + cmake --build . + +CMake Options +------------- + +.. list-table:: + :widths: 30 15 55 + :header-rows: 1 + + * - Option + - Default + - Description + * - ``USE_QT6`` + - ``ON`` + - Build with Qt6. Set to ``OFF`` for Qt5. + * - ``BUILD_SHARED_LIBS`` + - ``ON`` + - Build as shared library. Set to ``OFF`` for static. + * - ``BUILD_TESTING`` + - ``ON`` + - Build unit tests. Requires Catch2. + * - ``BUILD_EXAMPLES`` + - ``ON`` + - Build example applications. + +**Examples:** + +.. code-block:: bash + + # Build with Qt5 + cmake .. -DUSE_QT6=OFF + + # Build static library + cmake .. -DBUILD_SHARED_LIBS=OFF + + # Skip tests (if Catch2 not installed) + cmake .. -DBUILD_TESTING=OFF + +Using vcpkg +----------- + +If you use vcpkg for dependency management: + +.. code-block:: bash + + cmake .. -DCMAKE_TOOLCHAIN_FILE=/path/to/vcpkg/scripts/buildsystems/vcpkg.cmake + +Integration into Your Project +----------------------------- + +**Option 1: CMake subdirectory** + +Add QtNodes as a subdirectory in your project: + +.. code-block:: cmake + + add_subdirectory(external/nodeeditor) + target_link_libraries(your_app QtNodes::QtNodes) + +**Option 2: Installed library** + +After running ``cmake --install .``: + +.. code-block:: cmake + + find_package(QtNodes REQUIRED) + target_link_libraries(your_app QtNodes::QtNodes) + +Verifying Installation +---------------------- + +Run the calculator example to verify everything works: + +.. code-block:: bash + + ./bin/calculator + +.. image:: /_static/screenshots/calc-start.png + :alt: Calculator example running successfully + :width: 400px + +Next Steps +---------- + +Continue to :doc:`quickstart` to build your first node graph. diff --git a/docs/getting-started/quickstart.rst b/docs/getting-started/quickstart.rst new file mode 100644 index 000000000..fee0b8830 --- /dev/null +++ b/docs/getting-started/quickstart.rst @@ -0,0 +1,175 @@ +Quick Start +=========== + +This tutorial creates a simple node graph editor in under 50 lines of code. +By the end, you'll have an interactive canvas where you can create nodes, +connect them, and move them around. + +.. image:: /_static/screenshots/quickstart-result.png + :alt: The node graph we'll build in this tutorial + :align: center + +What We're Building +------------------- + +A minimal graph editor that: + +- Displays nodes on a canvas +- Lets you create new nodes via right-click menu +- Allows connecting nodes by dragging between ports +- Supports selecting, moving, and deleting nodes + +Step 1: Create the Graph Model +------------------------------ + +First, we need a class to store our graph data. Create ``SimpleGraphModel.hpp``: + +.. code-block:: cpp + + #pragma once + #include + #include + #include + + class SimpleGraphModel : public QtNodes::AbstractGraphModel + { + Q_OBJECT + public: + // Required: Generate unique node IDs + QtNodes::NodeId newNodeId() override { return _nextId++; } + + // Required: Return all node IDs + std::unordered_set allNodeIds() const override; + + // Required: Return all connections for a node + std::unordered_set allConnectionIds(QtNodes::NodeId) const override; + + // ... (see full implementation in examples/simple_graph_model) + + private: + QtNodes::NodeId _nextId = 0; + std::unordered_set _nodes; + std::unordered_set _connections; + }; + +The model inherits from ``AbstractGraphModel`` and stores: + +- A set of node IDs +- A set of connections (each connecting two ports) +- Node positions and other metadata + +.. tip:: + + For the complete implementation, see ``examples/simple_graph_model/SimpleGraphModel.cpp``. + It's about 150 lines implementing all the required virtual methods. + +Step 2: Create the Main Window +------------------------------ + +In ``main.cpp``: + +.. code-block:: cpp + + #include + #include + #include + #include "SimpleGraphModel.hpp" + + int main(int argc, char* argv[]) + { + QApplication app(argc, argv); + + // 1. Create the graph model + SimpleGraphModel model; + + // 2. Create a scene that visualizes the model + auto* scene = new QtNodes::BasicGraphicsScene(model); + + // 3. Create a view to display the scene + QtNodes::GraphicsView view(scene); + view.setWindowTitle("My First Node Graph"); + view.resize(800, 600); + view.show(); + + return app.exec(); + } + +Step 3: Add Node Creation +------------------------- + +Let users create nodes via right-click: + +.. code-block:: cpp + + // After creating the view... + view.setContextMenuPolicy(Qt::ActionsContextMenu); + + QAction* createAction = new QAction("Create Node", &view); + QObject::connect(createAction, &QAction::triggered, [&]() { + // Get mouse position in scene coordinates + QPointF pos = view.mapToScene(view.mapFromGlobal(QCursor::pos())); + + // Add node to model + auto nodeId = model.addNode(); + model.setNodeData(nodeId, QtNodes::NodeRole::Position, pos); + }); + + view.addAction(createAction); + +Step 4: Build and Run +--------------------- + +.. code-block:: cmake + + # CMakeLists.txt + find_package(QtNodes REQUIRED) + + add_executable(my_first_graph main.cpp SimpleGraphModel.cpp) + target_link_libraries(my_first_graph QtNodes::QtNodes) + +Run it: + +.. code-block:: bash + + mkdir build && cd build + cmake .. && make + ./my_first_graph + +Interacting with Your Graph +--------------------------- + +.. |icon_create| image:: /_static/screenshots/quickstart-create.png + :width: 300px + +.. |icon_connect| image:: /_static/screenshots/quickstart-connect.png + :width: 300px + +.. |icon_select| image:: /_static/screenshots/quickstart-select.png + :width: 300px + +.. list-table:: + :widths: 33 33 33 + + * - |icon_create| + - |icon_connect| + - |icon_select| + * - **Create**: Right-click canvas + - **Connect**: Drag from port to port + - **Select**: Click node or drag box + +**Keyboard shortcuts:** + +- ``Delete`` -- Remove selected nodes/connections +- ``Ctrl+Z`` -- Undo +- ``Ctrl+Shift+Z`` -- Redo +- ``Ctrl+D`` -- Duplicate selection + +What's Next? +------------ + +You've built a basic graph editor! Here's where to go next: + +- :doc:`concepts` -- Understand the Model-View architecture +- :doc:`/guide/data-flow` -- Make data flow between nodes +- :doc:`/guide/styling` -- Customize the look and feel +- :doc:`/examples/index` -- Explore complete examples diff --git a/docs/guide/advanced.rst b/docs/guide/advanced.rst new file mode 100644 index 000000000..601e4ef0c --- /dev/null +++ b/docs/guide/advanced.rst @@ -0,0 +1,289 @@ +Advanced Topics +=============== + +This guide covers dynamic ports, node locking, loop detection, and other +advanced features. + +Dynamic Ports +------------- + +Add or remove ports at runtime. This is useful for nodes with variable +inputs like "Add N Numbers" or "Merge Arrays". + +.. image:: /_static/screenshots/dynamic-ports.png + :alt: Node with dynamically added ports + :width: 500px + +.. + SCREENSHOT NEEDED: dynamic-ports.png + - Show dynamic_ports example + - Node with "Add Port" button + - Multiple dynamically added ports visible + - Size: ~300px wide + +**The process:** + +1. Call ``portsAboutToBeInserted()`` or ``portsAboutToBeDeleted()`` +2. Modify your internal data +3. Call ``portsInserted()`` or ``portsDeleted()`` + +.. code-block:: cpp + + void MyModel::addPortToNode(NodeId nodeId) + { + // 1. Prepare: library caches affected connections + portsAboutToBeInserted(nodeId, PortType::In, newPortIndex, newPortIndex); + + // 2. Update your data + _portCounts[nodeId]++; + + // 3. Complete: library restores shifted connections + portsInserted(); + } + + void MyModel::removePortFromNode(NodeId nodeId, PortIndex portIndex) + { + // 1. Prepare: library removes affected connections + portsAboutToBeDeleted(nodeId, PortType::In, portIndex, portIndex); + + // 2. Update your data + _portCounts[nodeId]--; + + // 3. Complete + portsDeleted(); + } + +**In NodeDelegateModel:** + +.. code-block:: cpp + + void MyDelegate::addPort() + { + emit portsAboutToBeInserted(PortType::In, newIndex, newIndex); + + _inputCount++; + + emit portsInserted(); + } + +.. warning:: + + Always use the two-phase approach. Modifying ports without the + ``portsAboutTo...`` / ``ports...`` calls will corrupt connection state. + +Locked Nodes +------------ + +Prevent nodes from being moved or selected: + +.. code-block:: cpp + + NodeFlags MyModel::nodeFlags(NodeId nodeId) const override + { + NodeFlags flags = AbstractGraphModel::nodeFlags(nodeId); + + if (shouldBeLocked(nodeId)) { + flags |= NodeFlag::Locked; + } + + return flags; + } + +Update at runtime: + +.. code-block:: cpp + + void MyModel::lockNode(NodeId nodeId) + { + _lockedNodes.insert(nodeId); + emit nodeFlagsUpdated(nodeId); // Tell scene to update + } + +.. image:: /_static/screenshots/locked-node.png + :alt: Locked node (grayed out or with lock icon) + :width: 400px + +.. + SCREENSHOT NEEDED: locked-node.png + - Show a locked node (from lock_nodes_and_connections example) + - Visually distinct from normal nodes + - Maybe slightly faded or with indicator + - Size: ~200px wide + +Resizable Nodes +--------------- + +Allow users to resize nodes (useful for embedded widgets): + +.. code-block:: cpp + + NodeFlags MyModel::nodeFlags(NodeId nodeId) const override + { + return NodeFlag::Resizable; + } + +Handle size changes: + +.. code-block:: cpp + + bool MyModel::setNodeData(NodeId nodeId, NodeRole role, QVariant value) override + { + if (role == NodeRole::Size) { + _nodeSizes[nodeId] = value.toSize(); + emit nodeUpdated(nodeId); + return true; + } + // ... + } + +Non-Detachable Connections +-------------------------- + +Prevent users from dragging connections away from certain nodes: + +.. code-block:: cpp + + bool MyModel::detachPossible(ConnectionId conn) const override + { + // Don't allow detaching from "output" nodes + if (isOutputNode(conn.outNodeId)) { + return false; + } + return true; + } + +Loop Detection +-------------- + +The ``AbstractGraphModel`` provides ``loopsEnabled()`` to control cyclic connections: + +.. code-block:: cpp + + // Default: loops allowed + bool AbstractGraphModel::loopsEnabled() const { return true; } + + // DataFlowGraphModel: loops disabled + bool DataFlowGraphModel::loopsEnabled() const override { return false; } + +When loops are disabled, ``connectionPossible()`` automatically rejects +connections that would create cycles. + +**Custom loop policy:** + +.. code-block:: cpp + + class MyModel : public AbstractGraphModel + { + public: + bool loopsEnabled() const override + { + return _allowLoops; // User-configurable + } + + void setLoopsAllowed(bool allowed) + { + _allowLoops = allowed; + } + + private: + bool _allowLoops = false; + }; + +Connection Policies +------------------- + +Control how many connections a port accepts: + +.. code-block:: cpp + + QVariant MyModel::portData(NodeId nodeId, PortType portType, + PortIndex portIndex, PortRole role) const override + { + if (role == PortRole::ConnectionPolicyRole) { + if (portType == PortType::In) { + // Inputs accept only one connection + return QVariant::fromValue(ConnectionPolicy::One); + } else { + // Outputs can connect to many inputs + return QVariant::fromValue(ConnectionPolicy::Many); + } + } + // ... + } + +Custom Connection Validation +---------------------------- + +Implement complex connection rules: + +.. code-block:: cpp + + bool MyModel::connectionPossible(ConnectionId conn) const override + { + // Basic checks + if (!nodeExists(conn.inNodeId) || !nodeExists(conn.outNodeId)) + return false; + + // No self-connections + if (conn.inNodeId == conn.outNodeId) + return false; + + // Type compatibility + auto outType = getOutputType(conn.outNodeId, conn.outPortIndex); + auto inType = getInputType(conn.inNodeId, conn.inPortIndex); + + if (!typesCompatible(outType, inType)) + return false; + + // Custom rule: max 3 connections to any input + if (connections(conn.inNodeId, PortType::In, conn.inPortIndex).size() >= 3) + return false; + + // Cycle detection (if loops disabled) + if (!loopsEnabled() && wouldCreateCycle(conn)) + return false; + + return true; + } + +Programmatic Graph Manipulation +------------------------------- + +Build graphs in code (useful for testing or loading custom formats): + +.. code-block:: cpp + + void buildGraph(AbstractGraphModel& model) + { + // Create nodes + NodeId source = model.addNode("Source"); + model.setNodeData(source, NodeRole::Position, QPointF(0, 0)); + + NodeId process = model.addNode("Process"); + model.setNodeData(process, NodeRole::Position, QPointF(200, 0)); + + NodeId output = model.addNode("Output"); + model.setNodeData(output, NodeRole::Position, QPointF(400, 0)); + + // Create connections + if (model.connectionPossible({source, 0, process, 0})) { + model.addConnection({source, 0, process, 0}); + } + + if (model.connectionPossible({process, 0, output, 0})) { + model.addConnection({process, 0, output, 0}); + } + } + +Node Groups (Future) +-------------------- + +.. note:: + + Node grouping is planned but not yet implemented. See the + `GitHub issues `_ for status. + +.. seealso:: + + - ``examples/dynamic_ports/`` -- Dynamic port example + - ``examples/lock_nodes_and_connections/`` -- Locking example diff --git a/docs/guide/data-flow.rst b/docs/guide/data-flow.rst new file mode 100644 index 000000000..daf6603e7 --- /dev/null +++ b/docs/guide/data-flow.rst @@ -0,0 +1,374 @@ +Data Flow Model +=============== + +The data flow model automates data propagation between nodes. When a node's +output changes, connected nodes automatically receive the new data. This is +ideal for visual programming, calculators, image processing pipelines, and +similar applications. + +.. image:: /_static/screenshots/calculator.png + :alt: Calculator example showing data flow + :align: center + :width: 500px + +Overview +-------- + +The data flow system has three main components: + +1. **DataFlowGraphModel** -- Manages nodes and routes data +2. **NodeDelegateModel** -- Your node logic (one class per node type) +3. **NodeDelegateModelRegistry** -- Factory for creating node instances + +Setting Up +---------- + +**1. Define your data type:** + +.. code-block:: cpp + + #include + + class NumberData : public QtNodes::NodeData + { + public: + NumberData(double value = 0.0) : _value(value) {} + + // Unique type identifier + QtNodes::NodeDataType type() const override { + return {"number", "Number"}; + } + + double value() const { return _value; } + + private: + double _value; + }; + +**2. Create a node delegate:** + +.. code-block:: cpp + + #include + + class AdditionNode : public QtNodes::NodeDelegateModel + { + Q_OBJECT + public: + // Identity + QString caption() const override { return "Add"; } + QString name() const override { return "Addition"; } + + // Ports + unsigned int nPorts(PortType type) const override { + return type == PortType::In ? 2 : 1; + } + + NodeDataType dataType(PortType, PortIndex) const override { + return NumberData{}.type(); + } + + // Data handling + void setInData(std::shared_ptr data, PortIndex port) override; + std::shared_ptr outData(PortIndex) override; + + // Widget (optional) + QWidget* embeddedWidget() override { return nullptr; } + + private: + std::shared_ptr _input1, _input2, _result; + }; + +**3. Register nodes and create the model:** + +.. code-block:: cpp + + auto registry = std::make_shared(); + + // Register with category for menu organization + registry->registerModel("Sources"); + registry->registerModel("Operators"); + registry->registerModel("Outputs"); + + DataFlowGraphModel model(registry); + DataFlowGraphicsScene scene(model); + +NodeDelegateModel Methods +------------------------- + +**Required methods:** + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Method + - Purpose + * - ``name()`` + - Unique identifier for serialization + * - ``caption()`` + - Display name on node + * - ``nPorts(PortType)`` + - Number of input/output ports + * - ``dataType(PortType, PortIndex)`` + - Type of data at each port + * - ``setInData(data, port)`` + - Receive data on input port + * - ``outData(port)`` + - Provide data from output port + * - ``embeddedWidget()`` + - Return widget or nullptr + +**Optional methods:** + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Method + - Purpose + * - ``captionVisible()`` + - Hide caption (default: true) + * - ``portCaption(type, index)`` + - Custom port labels + * - ``portCaptionVisible(type, index)`` + - Show/hide port labels + * - ``resizable()`` + - Allow node resizing (default: false) + * - ``save()`` / ``load()`` + - Custom serialization + +Implementing Data Flow +---------------------- + +**Receiving data (setInData):** + +.. code-block:: cpp + + void AdditionNode::setInData(std::shared_ptr data, PortIndex port) + { + auto numberData = std::dynamic_pointer_cast(data); + + if (port == 0) + _input1 = numberData; + else + _input2 = numberData; + + // Compute result + if (_input1 && _input2) { + double sum = _input1->value() + _input2->value(); + _result = std::make_shared(sum); + } else { + _result.reset(); // Invalid if inputs missing + } + + // Notify downstream nodes + emit dataUpdated(0); + } + +**Providing data (outData):** + +.. code-block:: cpp + + std::shared_ptr AdditionNode::outData(PortIndex) + { + return _result; + } + +**Signals to emit:** + +- ``dataUpdated(portIndex)`` -- Output data changed, propagate downstream +- ``dataInvalidated(portIndex)`` -- Output is no longer valid + +Data Flow Diagram +----------------- + +.. image:: /_static/diagrams/dataflow-signals.png + :alt: Signal flow diagram + :align: center + :width: 600px + +.. + SCREENSHOT NEEDED: dataflow-signals.png + Diagram showing the signal/slot flow: + + NodeDelegate A DataFlowGraphModel NodeDelegate B + | | | + | dataUpdated(0) | | + |------------------------>| | + | | calls outData(0) | + |<------------------------| | + | returns data | | + | | calls setInData(data,0) | + | |------------------------>| + | | | + | | (B may emit | + | | dataUpdated too) | + + Size: ~600px wide + +Embedded Widgets +---------------- + +Add interactive controls to your nodes: + +.. code-block:: cpp + + class NumberSourceNode : public NodeDelegateModel + { + public: + QWidget* embeddedWidget() override + { + if (!_spinBox) { + _spinBox = new QDoubleSpinBox(); + connect(_spinBox, &QDoubleSpinBox::valueChanged, + this, &NumberSourceNode::onValueChanged); + } + return _spinBox; + } + + private slots: + void onValueChanged(double value) + { + _data = std::make_shared(value); + emit dataUpdated(0); // Push new value downstream + } + + private: + QDoubleSpinBox* _spinBox = nullptr; + std::shared_ptr _data; + }; + +.. image:: /_static/screenshots/embedded-widget.png + :alt: Node with embedded spin box + :width: 400px + +Validation State +---------------- + +Use ``NodeValidationState`` to indicate whether a node's configuration is valid. +When the state is Warning or Error, a colored icon appears at the node's corner: + +- **Valid** (no icon) -- Node is properly configured +- **Warning** (orange icon) -- Some issues, but processing may work +- **Error** (red icon) -- Invalid configuration, cannot process + +The ``_stateMessage`` is displayed as a **tooltip** when hovering over the node. + +.. code-block:: cpp + + void MyNode::setInData(std::shared_ptr data, PortIndex) + { + if (!data) { + // Show warning with tooltip message + NodeValidationState state; + state._state = NodeValidationState::State::Warning; + state._stateMessage = "Missing input data"; // Shown on hover + setValidationState(state); + return; + } + + if (!isValidInput(data)) { + // Show error with tooltip message + NodeValidationState state; + state._state = NodeValidationState::State::Error; + state._stateMessage = "Invalid input format"; // Shown on hover + setValidationState(state); + return; + } + + // Input is valid - clear any previous state + NodeValidationState state; + state._state = NodeValidationState::State::Valid; + setValidationState(state); + + // Process data... + } + +Processing Status +----------------- + +Use ``NodeProcessingStatus`` to show computation state. A small status icon +appears at the node's corner indicating the current state: + +.. image:: /_static/screenshots/processing-status.png + :alt: Node showing processing status icon + :align: center + :width: 500px + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Status + - Meaning + * - ``NoStatus`` + - Default, no icon shown + * - ``Processing`` + - Computation in progress (spinner icon) + * - ``Updated`` + - Successfully completed + * - ``Pending`` + - Waiting for inputs + * - ``Empty`` + - No data available + * - ``Failed`` + - Computation failed + * - ``Partial`` + - Partially completed (some outputs ready) + +.. code-block:: cpp + + void MyNode::compute() + { + setNodeProcessingStatus(NodeProcessingStatus::Processing); + + // Start async computation... + QFuture future = QtConcurrent::run([this]() { + return heavyComputation(); + }); + + // When done: + watcher.setFuture(future); + connect(&watcher, &QFutureWatcher::finished, [this]() { + setNodeProcessingStatus(NodeProcessingStatus::Updated); + emit dataUpdated(0); + }); + } + +Type Compatibility +------------------ + +Connections are only allowed between compatible types. By default, types +must match exactly. Override ``connectionPossible()`` for custom logic: + +.. code-block:: cpp + + bool MyModel::connectionPossible(ConnectionId conn) const + { + auto outType = portData(conn.outNodeId, PortType::Out, + conn.outPortIndex, PortRole::DataType); + auto inType = portData(conn.inNodeId, PortType::In, + conn.inPortIndex, PortRole::DataType); + + // Allow Integer -> Number conversion + if (outType == "integer" && inType == "number") + return true; + + return outType == inType; + } + +Complete Example +---------------- + +See ``examples/calculator/`` for a complete data flow application with: + +- Multiple node types (sources, operators, display) +- Embedded widgets +- File save/load +- Custom connection colors + +.. seealso:: + + - :doc:`graph-models` -- For custom graph implementations + - :doc:`/examples/index` -- More examples diff --git a/docs/guide/graph-models.rst b/docs/guide/graph-models.rst new file mode 100644 index 000000000..3ce60bbc2 --- /dev/null +++ b/docs/guide/graph-models.rst @@ -0,0 +1,319 @@ +Graph Models +============ + +The graph model is the core of your application. It stores nodes, connections, +and all associated data. This guide covers implementing your own model by +subclassing ``AbstractGraphModel``. + +.. tip:: + + If you want automatic data propagation between nodes, see :doc:`data-flow` + instead. Use a custom graph model when you need full control over graph logic. + +AbstractGraphModel Overview +--------------------------- + +Your model must implement these pure virtual methods: + +.. code-block:: cpp + + class MyGraphModel : public QtNodes::AbstractGraphModel + { + public: + // ID generation + NodeId newNodeId() override; + + // Node queries + std::unordered_set allNodeIds() const override; + bool nodeExists(NodeId) const override; + QVariant nodeData(NodeId, NodeRole) const override; + bool setNodeData(NodeId, NodeRole, QVariant) override; + + // Connection queries + std::unordered_set allConnectionIds(NodeId) const override; + std::unordered_set connections(NodeId, PortType, PortIndex) const override; + bool connectionExists(ConnectionId) const override; + bool connectionPossible(ConnectionId) const override; + + // Mutations + NodeId addNode(QString nodeType) override; + void addConnection(ConnectionId) override; + bool deleteNode(NodeId) override; + bool deleteConnection(ConnectionId) override; + + // Port queries + QVariant portData(NodeId, PortType, PortIndex, PortRole) const override; + bool setPortData(NodeId, PortType, PortIndex, QVariant, PortRole) override; + }; + +Implementing Node Management +---------------------------- + +**ID Generation** + +Generate unique IDs. A simple counter works: + +.. code-block:: cpp + + NodeId MyGraphModel::newNodeId() + { + return _nextId++; + } + +**Adding Nodes** + +Store the node and emit the signal: + +.. code-block:: cpp + + NodeId MyGraphModel::addNode(QString nodeType) + { + NodeId id = newNodeId(); + _nodes.insert(id); + _nodeTypes[id] = nodeType; + _nodePositions[id] = QPointF(0, 0); + + emit nodeCreated(id); // Required! + return id; + } + +**Deleting Nodes** + +Remove connections first, then the node: + +.. code-block:: cpp + + bool MyGraphModel::deleteNode(NodeId nodeId) + { + if (!nodeExists(nodeId)) + return false; + + // Remove all connections involving this node + for (auto& conn : allConnectionIds(nodeId)) { + deleteConnection(conn); + } + + _nodes.erase(nodeId); + emit nodeDeleted(nodeId); // Required! + return true; + } + +NodeRole Reference +------------------ + +Implement ``nodeData()`` and ``setNodeData()`` to provide node information: + +.. list-table:: + :widths: 20 15 65 + :header-rows: 1 + + * - Role + - Type + - Description + * - ``Type`` + - ``QString`` + - Node type identifier (e.g., "AddNode", "ImageFilter") + * - ``Position`` + - ``QPointF`` + - Position on the canvas + * - ``Size`` + - ``QSize`` + - Size hint for embedded widgets + * - ``Caption`` + - ``QString`` + - Display name shown on the node + * - ``CaptionVisible`` + - ``bool`` + - Whether to show the caption + * - ``Style`` + - ``QVariantMap`` + - Per-node style overrides (JSON) + * - ``InternalData`` + - ``QVariantMap`` + - Custom data for serialization + * - ``InPortCount`` + - ``unsigned int`` + - Number of input ports + * - ``OutPortCount`` + - ``unsigned int`` + - Number of output ports + * - ``Widget`` + - ``QWidget*`` + - Embedded widget (or nullptr) + * - ``ValidationState`` + - ``NodeValidationState`` + - Current validation state + * - ``ProcessingStatus`` + - ``NodeProcessingStatus`` + - Current processing status + +**Example implementation:** + +.. code-block:: cpp + + QVariant MyGraphModel::nodeData(NodeId nodeId, NodeRole role) const + { + switch (role) { + case NodeRole::Type: + return _nodeTypes.value(nodeId); + + case NodeRole::Position: + return _nodePositions.value(nodeId); + + case NodeRole::Caption: + return QString("Node %1").arg(nodeId); + + case NodeRole::InPortCount: + return 2u; // All nodes have 2 inputs + + case NodeRole::OutPortCount: + return 1u; // All nodes have 1 output + + default: + return {}; + } + } + +Implementing Connections +------------------------ + +**Connection Queries** + +Return connections filtered by node and port: + +.. code-block:: cpp + + std::unordered_set + MyGraphModel::connections(NodeId nodeId, PortType portType, PortIndex portIndex) const + { + std::unordered_set result; + for (const auto& conn : _connections) { + if (portType == PortType::In && + conn.inNodeId == nodeId && + conn.inPortIndex == portIndex) { + result.insert(conn); + } + else if (portType == PortType::Out && + conn.outNodeId == nodeId && + conn.outPortIndex == portIndex) { + result.insert(conn); + } + } + return result; + } + +**Connection Validation** + +Control what connections are allowed: + +.. code-block:: cpp + + bool MyGraphModel::connectionPossible(ConnectionId conn) const + { + // Nodes must exist + if (!nodeExists(conn.inNodeId) || !nodeExists(conn.outNodeId)) + return false; + + // No self-connections + if (conn.inNodeId == conn.outNodeId) + return false; + + // No duplicate connections + if (connectionExists(conn)) + return false; + + // Custom logic: check port compatibility, etc. + return true; + } + +PortRole Reference +------------------ + +Implement ``portData()`` for port-specific information: + +.. list-table:: + :widths: 25 15 60 + :header-rows: 1 + + * - Role + - Type + - Description + * - ``Data`` + - ``std::shared_ptr`` + - The actual data at this port + * - ``DataType`` + - ``NodeDataType`` + - Type descriptor for compatibility checks + * - ``ConnectionPolicyRole`` + - ``ConnectionPolicy`` + - ``One`` (single connection) or ``Many`` + * - ``Caption`` + - ``QString`` + - Port label text + * - ``CaptionVisible`` + - ``bool`` + - Whether to show the label + +Required Signals +---------------- + +Your model **must** emit these signals at the appropriate times: + +.. code-block:: cpp + + // After adding a node + emit nodeCreated(nodeId); + + // After removing a node + emit nodeDeleted(nodeId); + + // After node data changes (caption, style, etc.) + emit nodeUpdated(nodeId); + + // After position changes specifically + emit nodePositionUpdated(nodeId); + + // After adding a connection + emit connectionCreated(connectionId); + + // After removing a connection + emit connectionDeleted(connectionId); + +.. warning:: + + Forgetting to emit signals will cause the view to become out of sync + with your model. + +Serialization Support +--------------------- + +Override ``saveNode()`` and ``loadNode()`` to support save/load: + +.. code-block:: cpp + + QJsonObject MyGraphModel::saveNode(NodeId nodeId) const + { + QJsonObject json; + json["id"] = static_cast(nodeId); + json["type"] = _nodeTypes[nodeId]; + + QJsonObject pos; + pos["x"] = _nodePositions[nodeId].x(); + pos["y"] = _nodePositions[nodeId].y(); + json["position"] = pos; + + return json; + } + +See :doc:`serialization` for complete save/load implementation. + +Complete Example +---------------- + +See ``examples/simple_graph_model/`` for a complete, working implementation +of a custom graph model. + +.. seealso:: + + - :doc:`data-flow` -- For automatic data propagation + - :doc:`/api/classes` -- Full API reference diff --git a/docs/guide/serialization.rst b/docs/guide/serialization.rst new file mode 100644 index 000000000..0d5f53958 --- /dev/null +++ b/docs/guide/serialization.rst @@ -0,0 +1,254 @@ +Serialization +============= + +Save and load graphs as JSON. + +JSON Structure +-------------- + +A saved graph contains nodes and connections: + +.. code-block:: json + + { + "nodes": [ + { + "id": 0, + "position": {"x": 100, "y": 50}, + "internal-data": { + "model-name": "NumberSource", + "value": 42.0 + } + }, + { + "id": 1, + "position": {"x": 300, "y": 50}, + "internal-data": { + "model-name": "Display" + } + } + ], + "connections": [ + { + "outNodeId": 0, + "outPortIndex": 0, + "inNodeId": 1, + "inPortIndex": 0 + } + ] + } + +Using DataFlowGraphModel +------------------------ + +``DataFlowGraphModel`` implements ``Serializable``: + +.. code-block:: cpp + + DataFlowGraphModel model(registry); + + // Save to JSON object + QJsonObject json = model.save(); + + // Save to file + QFile file("graph.json"); + file.open(QIODevice::WriteOnly); + file.write(QJsonDocument(json).toJson()); + + // Load from file + file.open(QIODevice::ReadOnly); + QJsonObject loadedJson = QJsonDocument::fromJson(file.readAll()).object(); + model.load(loadedJson); + +Using DataFlowGraphicsScene +--------------------------- + +The scene provides file dialogs: + +.. code-block:: cpp + + DataFlowGraphicsScene scene(model); + + // Opens save dialog, returns true on success + if (scene.save()) { + qDebug() << "Saved!"; + } + + // Opens load dialog + scene.load(); + + // React to load completion + connect(&scene, &DataFlowGraphicsScene::sceneLoaded, [&view]() { + view.centerScene(); + }); + +Custom Model Serialization +-------------------------- + +For custom ``AbstractGraphModel`` subclasses, implement ``saveNode()`` and ``loadNode()``: + +.. code-block:: cpp + + QJsonObject MyModel::saveNode(NodeId nodeId) const + { + QJsonObject json; + + // Required: ID + json["id"] = static_cast(nodeId); + + // Required: Position + QPointF pos = nodeData(nodeId, NodeRole::Position).toPointF(); + json["position"] = QJsonObject{{"x", pos.x()}, {"y", pos.y()}}; + + // Optional: Your custom data + json["internal-data"] = QJsonObject{ + {"type", _nodeTypes[nodeId]}, + {"custom-field", _customData[nodeId]} + }; + + return json; + } + + void MyModel::loadNode(QJsonObject const& json) + { + NodeId nodeId = static_cast(json["id"].toInt()); + + // Ensure unique IDs + _nextId = std::max(_nextId, nodeId + 1); + + // Create node + _nodes.insert(nodeId); + emit nodeCreated(nodeId); + + // Restore position + QJsonObject posJson = json["position"].toObject(); + setNodeData(nodeId, NodeRole::Position, + QPointF(posJson["x"].toDouble(), posJson["y"].toDouble())); + + // Restore custom data + QJsonObject internal = json["internal-data"].toObject(); + _nodeTypes[nodeId] = internal["type"].toString(); + _customData[nodeId] = internal["custom-field"].toString(); + } + +Then implement full save/load: + +.. code-block:: cpp + + QJsonObject MyModel::save() const + { + QJsonArray nodesArray; + for (NodeId nodeId : allNodeIds()) { + nodesArray.append(saveNode(nodeId)); + } + + QJsonArray connectionsArray; + for (NodeId nodeId : allNodeIds()) { + for (auto& conn : allConnectionIds(nodeId)) { + // Avoid duplicates: only save from output side + if (conn.outNodeId == nodeId) { + connectionsArray.append(toJson(conn)); + } + } + } + + return QJsonObject{ + {"nodes", nodesArray}, + {"connections", connectionsArray} + }; + } + + void MyModel::load(QJsonObject const& json) + { + // Clear existing + for (NodeId id : allNodeIds()) { + deleteNode(id); + } + + // Load nodes + for (auto nodeValue : json["nodes"].toArray()) { + loadNode(nodeValue.toObject()); + } + + // Load connections + for (auto connValue : json["connections"].toArray()) { + ConnectionId conn = fromJson(connValue.toObject()); + addConnection(conn); + } + } + +NodeDelegateModel Serialization +------------------------------- + +Delegates can save custom state: + +.. code-block:: cpp + + class MyNode : public NodeDelegateModel + { + public: + QJsonObject save() const override + { + QJsonObject json = NodeDelegateModel::save(); + json["my-value"] = _spinBox->value(); + return json; + } + + void load(QJsonObject const& json) override + { + NodeDelegateModel::load(json); + _spinBox->setValue(json["my-value"].toDouble()); + } + + private: + QDoubleSpinBox* _spinBox; + }; + +Connection ID Utilities +----------------------- + +Helper functions in ``ConnectionIdUtils.hpp``: + +.. code-block:: cpp + + #include + + // Convert to/from JSON + QJsonObject json = QtNodes::toJson(connectionId); + ConnectionId conn = QtNodes::fromJson(json); + +Complete Save/Load Example +-------------------------- + +.. code-block:: cpp + + // In your main window + QAction* saveAction = fileMenu->addAction("Save", [&]() { + QString path = QFileDialog::getSaveFileName( + this, "Save Graph", "", "JSON Files (*.json)"); + if (path.isEmpty()) return; + + QFile file(path); + if (file.open(QIODevice::WriteOnly)) { + QJsonDocument doc(model.save()); + file.write(doc.toJson(QJsonDocument::Indented)); + } + }); + + QAction* loadAction = fileMenu->addAction("Load", [&]() { + QString path = QFileDialog::getOpenFileName( + this, "Load Graph", "", "JSON Files (*.json)"); + if (path.isEmpty()) return; + + QFile file(path); + if (file.open(QIODevice::ReadOnly)) { + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + model.load(doc.object()); + view.centerScene(); + } + }); + +.. seealso:: + + - ``examples/calculator/`` -- Save/load implementation + - :doc:`undo-redo` -- Undo uses serialization internally diff --git a/docs/guide/styling.rst b/docs/guide/styling.rst new file mode 100644 index 000000000..344cb25d0 --- /dev/null +++ b/docs/guide/styling.rst @@ -0,0 +1,295 @@ +Styling +======= + +Customize the appearance of nodes, connections, and the canvas. + +Style Architecture +------------------ + +Styles are managed by ``StyleCollection`` and stored as JSON internally: + +- **GraphicsViewStyle** -- Canvas background and grid +- **NodeStyle** -- Node colors, borders, fonts +- **ConnectionStyle** -- Connection lines and colors + +.. image:: /_static/screenshots/style-example.png + :alt: Example of styled nodes + :align: center + :width: 600px + +Setting Styles Globally +----------------------- + +Apply styles to all new objects: + +.. code-block:: cpp + + #include + #include + #include + #include + + // From JSON string + NodeStyle::setNodeStyle(R"({ + "NodeStyle": { + "NormalBoundaryColor": [255, 255, 255], + "SelectedBoundaryColor": [255, 200, 0], + "GradientColor0": [40, 40, 40], + "GradientColor1": [60, 60, 60], + "GradientColor2": [50, 50, 50], + "GradientColor3": [45, 45, 45], + "ShadowColor": [20, 20, 20], + "ShadowEnabled": true, + "FontColor": "white", + "PenWidth": 2.0, + "Opacity": 0.9 + } + })"); + +GraphicsViewStyle +----------------- + +Controls the canvas appearance: + +.. code-block:: json + + { + "GraphicsViewStyle": { + "BackgroundColor": [53, 53, 53], + "FineGridColor": [60, 60, 60], + "CoarseGridColor": [25, 25, 25] + } + } + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Property + - Description + * - ``BackgroundColor`` + - Main canvas background + * - ``FineGridColor`` + - Small grid lines + * - ``CoarseGridColor`` + - Major grid lines + +NodeStyle +--------- + +Controls node appearance: + +.. code-block:: json + + { + "NodeStyle": { + "NormalBoundaryColor": [255, 255, 255], + "SelectedBoundaryColor": [255, 165, 0], + "GradientColor0": "gray", + "GradientColor1": [80, 80, 80], + "GradientColor2": [64, 64, 64], + "GradientColor3": [58, 58, 58], + "ShadowColor": [20, 20, 20], + "ShadowEnabled": true, + "FontColor": "white", + "FontColorFaded": "gray", + "ConnectionPointColor": [169, 169, 169], + "FilledConnectionPointColor": "cyan", + "ErrorColor": [211, 47, 47], + "WarningColor": [255, 179, 0], + "ToolTipIconColor": "white", + "PenWidth": 1.0, + "HoveredPenWidth": 1.5, + "ConnectionPointDiameter": 8.0, + "Opacity": 0.8 + } + } + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Property + - Description + * - ``NormalBoundaryColor`` + - Border when not selected + * - ``SelectedBoundaryColor`` + - Border when selected + * - ``GradientColor0-3`` + - Background gradient stops + * - ``ShadowEnabled`` + - Drop shadow on/off + * - ``ConnectionPointColor`` + - Empty port circles + * - ``FilledConnectionPointColor`` + - Connected port circles + * - ``ErrorColor`` + - Validation error indicator + * - ``WarningColor`` + - Validation warning indicator + +ConnectionStyle +--------------- + +Controls connection line appearance: + +.. code-block:: json + + { + "ConnectionStyle": { + "ConstructionColor": "gray", + "NormalColor": "darkcyan", + "SelectedColor": [100, 100, 100], + "SelectedHaloColor": "orange", + "HoveredColor": "lightcyan", + "LineWidth": 3.0, + "ConstructionLineWidth": 2.0, + "PointDiameter": 10.0, + "UseDataDefinedColors": false + } + } + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Property + - Description + * - ``ConstructionColor`` + - Color while dragging new connection + * - ``NormalColor`` + - Default connection color + * - ``SelectedColor`` + - Selected connection + * - ``HoveredColor`` + - Mouse hover color + * - ``UseDataDefinedColors`` + - Color by data type (see below) + +Data-Defined Connection Colors +------------------------------ + +Color connections based on data type: + +.. code-block:: cpp + + ConnectionStyle::setConnectionStyle(R"({ + "ConnectionStyle": { + "UseDataDefinedColors": true + } + })"); + +Then define colors in your ``NodeDataType``: + +.. code-block:: cpp + + NodeDataType NumberData::type() const + { + return NodeDataType{ + "number", // id + "Number", // name + QColor(0, 128, 255) // color for connections + }; + } + +.. image:: /_static/screenshots/connection-colors.png + :alt: Colored connections by data type + :width: 400px + +.. + SCREENSHOT NEEDED: connection-colors.png + - Show connection_colors example + - Multiple data types with different colored connections + - Clear visual distinction between types + - Size: ~400px wide + +Per-Node Styling +---------------- + +Override styles for individual nodes via ``NodeRole::Style``: + +.. code-block:: cpp + + QVariant MyModel::nodeData(NodeId nodeId, NodeRole role) const + { + if (role == NodeRole::Style) { + NodeStyle style = StyleCollection::nodeStyle(); + + // Custom color for this node + if (isErrorNode(nodeId)) { + style.GradientColor0 = QColor(100, 40, 40); + } + + return style.toJson().toVariantMap(); + } + // ... + } + +Or in a ``NodeDelegateModel``: + +.. code-block:: cpp + + class MyNode : public NodeDelegateModel + { + public: + MyNode() + { + // Set background color + setBackgroundColor(QColor(60, 100, 60)); + } + }; + +Loading Styles from File +------------------------ + +Load styles from external JSON: + +.. code-block:: cpp + + QFile file("my_style.json"); + if (file.open(QIODevice::ReadOnly)) { + QByteArray data = file.readAll(); + QString json = QString::fromUtf8(data); + + NodeStyle::setNodeStyle(json); + ConnectionStyle::setConnectionStyle(json); + GraphicsViewStyle::setGraphicsViewStyle(json); + } + +Example Style File +------------------ + +Complete style file (``my_style.json``): + +.. code-block:: json + + { + "GraphicsViewStyle": { + "BackgroundColor": [30, 30, 30], + "FineGridColor": [40, 40, 40], + "CoarseGridColor": [20, 20, 20] + }, + "NodeStyle": { + "NormalBoundaryColor": [100, 100, 100], + "SelectedBoundaryColor": [255, 180, 0], + "GradientColor0": [50, 50, 55], + "GradientColor1": [45, 45, 50], + "GradientColor2": [40, 40, 45], + "GradientColor3": [35, 35, 40], + "ShadowEnabled": true, + "FontColor": [220, 220, 220], + "PenWidth": 1.5, + "Opacity": 0.95 + }, + "ConnectionStyle": { + "NormalColor": [80, 180, 220], + "SelectedColor": [255, 180, 0], + "LineWidth": 2.5, + "UseDataDefinedColors": true + } + } + +.. seealso:: + + - ``examples/styles/`` -- Style customization example + - ``examples/connection_colors/`` -- Data-defined colors example diff --git a/docs/guide/undo-redo.rst b/docs/guide/undo-redo.rst new file mode 100644 index 000000000..4912863e6 --- /dev/null +++ b/docs/guide/undo-redo.rst @@ -0,0 +1,191 @@ +Undo/Redo +========= + +QtNodes provides built-in undo/redo support using Qt's undo framework. + +How It Works +------------ + +The ``BasicGraphicsScene`` maintains a ``QUndoStack``. User actions +automatically push commands to this stack: + +- Creating nodes +- Deleting nodes +- Creating connections +- Deleting connections +- Moving nodes +- Duplicating nodes + +Accessing the Undo Stack +------------------------ + +.. code-block:: cpp + + BasicGraphicsScene scene(model); + + // Get the undo stack + QUndoStack& undoStack = scene.undoStack(); + + // Wire up to UI + QAction* undoAction = undoStack.createUndoAction(this, "Undo"); + undoAction->setShortcut(QKeySequence::Undo); + + QAction* redoAction = undoStack.createRedoAction(this, "Redo"); + redoAction->setShortcut(QKeySequence::Redo); + + editMenu->addAction(undoAction); + editMenu->addAction(redoAction); + +Built-in Commands +----------------- + +QtNodes provides these ``QUndoCommand`` implementations in ``UndoCommands.cpp``: + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Command + - Description + * - ``DeleteCommand`` + - Removes nodes and their connections + * - ``DuplicateCommand`` + - Duplicates selected nodes + * - ``DisconnectCommand`` + - Removes a connection + * - ``ConnectCommand`` + - Creates a connection + * - ``MoveNodeCommand`` + - Moves a node to a new position + +Serialization Requirement +------------------------- + +Undo/redo for node deletion requires serialization support. Make sure your +model implements ``saveNode()`` and ``loadNode()``: + +.. code-block:: cpp + + QJsonObject MyModel::saveNode(NodeId nodeId) const override + { + // Save all data needed to recreate this node + QJsonObject json; + json["id"] = static_cast(nodeId); + + QPointF pos = nodeData(nodeId, NodeRole::Position).toPointF(); + json["position"] = QJsonObject{{"x", pos.x()}, {"y", pos.y()}}; + + // Save your custom data too + json["internal-data"] = getNodeInternalData(nodeId); + + return json; + } + + void MyModel::loadNode(QJsonObject const& json) override + { + // Recreate node from saved data + NodeId nodeId = static_cast(json["id"].toInt()); + _nextId = std::max(_nextId, nodeId + 1); + + _nodes.insert(nodeId); + emit nodeCreated(nodeId); + + // Restore position + auto pos = json["position"].toObject(); + setNodeData(nodeId, NodeRole::Position, + QPointF(pos["x"].toDouble(), pos["y"].toDouble())); + + // Restore custom data + restoreNodeInternalData(nodeId, json["internal-data"].toObject()); + } + +.. warning:: + + Without proper ``saveNode()``/``loadNode()``, deleted nodes cannot be + restored by undo. + +Custom Undo Commands +-------------------- + +Create custom commands for your own operations: + +.. code-block:: cpp + + class ChangeNodeColorCommand : public QUndoCommand + { + public: + ChangeNodeColorCommand(MyModel* model, NodeId nodeId, QColor newColor) + : _model(model) + , _nodeId(nodeId) + , _newColor(newColor) + , _oldColor(model->getNodeColor(nodeId)) + { + setText(QString("Change color of node %1").arg(nodeId)); + } + + void undo() override { + _model->setNodeColor(_nodeId, _oldColor); + } + + void redo() override { + _model->setNodeColor(_nodeId, _newColor); + } + + private: + MyModel* _model; + NodeId _nodeId; + QColor _newColor; + QColor _oldColor; + }; + + // Push to stack + scene.undoStack().push( + new ChangeNodeColorCommand(&model, nodeId, Qt::red) + ); + +Grouping Commands +----------------- + +Use macros to group multiple commands: + +.. code-block:: cpp + + undoStack.beginMacro("Batch Operation"); + + // These will undo/redo together + undoStack.push(new Command1(...)); + undoStack.push(new Command2(...)); + undoStack.push(new Command3(...)); + + undoStack.endMacro(); + +Clearing History +---------------- + +.. code-block:: cpp + + // Clear all undo history + undoStack.clear(); + + // Mark current state as "clean" (for save indicators) + undoStack.setClean(); + + // Check if modified since last save + if (!undoStack.isClean()) { + // Show "unsaved changes" warning + } + +Keyboard Shortcuts +------------------ + +Default shortcuts (handled by ``GraphicsView``): + +- ``Ctrl+Z`` -- Undo +- ``Ctrl+Shift+Z`` or ``Ctrl+Y`` -- Redo +- ``Delete`` -- Delete selection (creates ``DeleteCommand``) +- ``Ctrl+D`` -- Duplicate (creates ``DuplicateCommand``) + +.. seealso:: + + - `Qt Undo Framework `_ + - :doc:`serialization` -- Required for undo/redo diff --git a/docs/guide/visualization.rst b/docs/guide/visualization.rst new file mode 100644 index 000000000..6e2265f02 --- /dev/null +++ b/docs/guide/visualization.rst @@ -0,0 +1,296 @@ +Visualization +============= + +This guide covers the graphics system: scenes, views, painters, and geometry. + +Scene and View +-------------- + +QtNodes uses Qt's Graphics View Framework: + +- **BasicGraphicsScene** -- A ``QGraphicsScene`` that creates visual objects for your model +- **GraphicsView** -- A ``QGraphicsView`` with node-editing interactions built in + +.. code-block:: cpp + + // Connect model to scene to view + MyGraphModel model; + BasicGraphicsScene scene(model); + GraphicsView view(&scene); + view.show(); + +**For data flow applications**, use the specialized scene: + +.. code-block:: cpp + + DataFlowGraphModel model(registry); + DataFlowGraphicsScene scene(model); // Handles delegate creation + GraphicsView view(&scene); + +Graphics Objects +---------------- + +The scene creates graphics objects automatically: + +- **NodeGraphicsObject** -- Visual representation of each node +- **ConnectionGraphicsObject** -- Visual representation of each connection + +Access them when needed: + +.. code-block:: cpp + + NodeGraphicsObject* nodeObj = scene.nodeGraphicsObject(nodeId); + ConnectionGraphicsObject* connObj = scene.connectionGraphicsObject(connId); + +Custom Node Painter +------------------- + +Replace the default node appearance by subclassing ``AbstractNodePainter``: + +.. code-block:: cpp + + class MyNodePainter : public QtNodes::AbstractNodePainter + { + public: + void paint(QPainter* painter, NodeGraphicsObject& ngo) const override + { + auto& geometry = ngo.nodeScene()->nodeGeometry(); + auto& model = ngo.graphModel(); + NodeId nodeId = ngo.nodeId(); + + QRectF rect = geometry.boundingRect(nodeId); + + // Custom drawing + painter->setBrush(QColor(70, 130, 180)); + painter->setPen(QPen(Qt::white, 2)); + painter->drawRoundedRect(rect, 10, 10); + + // Draw caption + QString caption = model.nodeData(nodeId, NodeRole::Caption).toString(); + painter->drawText(rect, Qt::AlignCenter, caption); + } + }; + + // Register with scene + scene.setNodePainter(std::make_unique()); + +.. image:: /_static/screenshots/custom-painter.png + :alt: Nodes with custom painting + :width: 400px + +.. + SCREENSHOT NEEDED: custom-painter.png + - Show the custom_painter example running + - Display nodes with the blue-purple gradient style + - Show connections with arrows + - Size: ~400px wide + +Custom Connection Painter +------------------------- + +Similarly for connections: + +.. code-block:: cpp + + class MyConnectionPainter : public QtNodes::AbstractConnectionPainter + { + public: + void paint(QPainter* painter, ConnectionGraphicsObject const& cgo) const override + { + QPointF start = cgo.endPoint(PortType::Out); + QPointF end = cgo.endPoint(PortType::In); + + // Draw as straight line with arrow + painter->setPen(QPen(Qt::cyan, 3)); + painter->drawLine(start, end); + + // Draw arrow head at end + drawArrow(painter, end, start); + } + + QPainterPath getPainterStroke(ConnectionGraphicsObject const& cgo) const override + { + // Return path for hit testing + QPainterPath path; + path.moveTo(cgo.endPoint(PortType::Out)); + path.lineTo(cgo.endPoint(PortType::In)); + + QPainterPathStroker stroker; + stroker.setWidth(10); + return stroker.createStroke(path); + } + }; + + scene.setConnectionPainter(std::make_unique()); + +Custom Node Geometry +-------------------- + +Control node layout by subclassing ``AbstractNodeGeometry``: + +.. code-block:: cpp + + class MyNodeGeometry : public QtNodes::AbstractNodeGeometry + { + public: + QRectF boundingRect(NodeId nodeId) const override; + QPointF portPosition(NodeId, PortType, PortIndex) const override; + QPointF captionPosition(NodeId) const override; + QPointF widgetPosition(NodeId) const override; + // ... other layout methods + }; + + scene.setNodeGeometry(std::make_unique()); + +The library provides two built-in geometries: + +- ``DefaultHorizontalNodeGeometry`` -- Ports on left/right (default) +- ``DefaultVerticalNodeGeometry`` -- Ports on top/bottom + +Vertical Layout +--------------- + +For top-to-bottom graphs: + +.. code-block:: cpp + + scene.setOrientation(Qt::Vertical); + +.. image:: /_static/screenshots/vertical-layout.png + :alt: Vertical node layout + :width: 500px + +.. + SCREENSHOT NEEDED: vertical-layout.png + - Show vertical_layout example + - Nodes with ports on top and bottom + - Connections flowing downward + - Size: ~300px wide + +GraphicsView Features +--------------------- + +**Zoom control:** + +.. code-block:: cpp + + // Set zoom limits (0 = unlimited) + view.setScaleRange(0.25, 4.0); + + // Zoom programmatically + view.scaleUp(); + view.scaleDown(); + view.setupScale(1.5); + + // Fit content + view.zoomFitAll(); // Fit all nodes + view.zoomFitSelected(); // Fit selected nodes + + // React to zoom changes + connect(&view, &GraphicsView::scaleChanged, [](double scale) { + qDebug() << "Zoom:" << scale * 100 << "%"; + }); + +**Built-in actions:** + +.. code-block:: cpp + + view.clearSelectionAction(); // Clear selection + view.deleteSelectionAction(); // Delete selected items + +**Copy/paste:** + +.. code-block:: cpp + + view.onCopySelectedObjects(); + view.onPasteObjects(); + view.onDuplicateSelectedObjects(); // Ctrl+D + +Scene Signals +------------- + +Connect to user interactions: + +.. code-block:: cpp + + // Node interactions + connect(&scene, &BasicGraphicsScene::nodeClicked, [](NodeId id) { + qDebug() << "Clicked node" << id; + }); + + connect(&scene, &BasicGraphicsScene::nodeDoubleClicked, [](NodeId id) { + // Open node properties dialog + }); + + connect(&scene, &BasicGraphicsScene::nodeMoved, [](NodeId id, QPointF pos) { + qDebug() << "Node" << id << "moved to" << pos; + }); + + // Hover events (for tooltips, highlights) + connect(&scene, &BasicGraphicsScene::nodeHovered, + [](NodeId id, QPoint screenPos) { + QToolTip::showText(screenPos, "Node info here"); + }); + + connect(&scene, &BasicGraphicsScene::connectionHovered, + [](ConnectionId id, QPoint screenPos) { + // Show connection info + }); + + // Context menus + connect(&scene, &BasicGraphicsScene::nodeContextMenu, + [](NodeId id, QPointF scenePos) { + // Show custom context menu for this node + }); + +Context Menus +------------- + +Override ``createSceneMenu()`` for custom right-click menus: + +.. code-block:: cpp + + class MyScene : public DataFlowGraphicsScene + { + public: + QMenu* createSceneMenu(QPointF scenePos) override + { + auto* menu = new QMenu(); + + for (auto& category : _registry->categories()) { + auto* submenu = menu->addMenu(category); + for (auto& modelName : _registry->registeredModelsByCategory(category)) { + submenu->addAction(modelName, [=]() { + // Create node at scenePos + }); + } + } + + return menu; + } + }; + +Headless Mode +------------- + +Run without any visualization: + +.. code-block:: cpp + + // Model works without scene + DataFlowGraphModel model(registry); + + NodeId source = model.addNode("NumberSource"); + NodeId display = model.addNode("Display"); + model.addConnection({source, 0, display, 0}); + + // Data flows, computations happen, no GUI needed + + // Or: scene without view + BasicGraphicsScene scene(model); + // Scene tracks model changes but nothing is rendered + +.. seealso:: + + - :doc:`styling` -- Customize colors and styles + - :doc:`/examples/index` -- Visual examples diff --git a/docs/index.rst b/docs/index.rst index 9b92f2c6c..3b5c068f0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,21 +1,103 @@ -QtNodes Documentation -===================== +QtNodes +======= -.. image:: /_static/calculator.png +**A Qt-based library for building node graph editors** + +.. image:: /_static/screenshots/calculator.png + :alt: QtNodes editor showing a calculator example + :align: center + +QtNodes lets you create interactive node-based interfaces for visual programming, +data flow applications, shader editors, state machines, and more. Built on Qt's +Model-View architecture, it separates your data from its visual representation. + +.. code-block:: cpp + + // Create a graph and display it in 5 lines + DataFlowGraphModel model(registry); + DataFlowGraphicsScene scene(model); + GraphicsView view(&scene); + view.show(); + +Key Features +------------ + +- **Model-View Architecture** -- Your graph data stays independent of the UI +- **Data Flow Support** -- Built-in data propagation between connected nodes +- **Headless Mode** -- Run graph computations without any GUI +- **Customizable** -- Custom painters, styles, and node geometries +- **Serialization** -- Save and load graphs as JSON +- **Undo/Redo** -- Built-in support via Qt's undo framework + +Two Approaches +-------------- + +.. list-table:: + :widths: 50 50 + :header-rows: 1 + + * - Simple Graph Visualization + - Data Flow Processing + * - Use ``AbstractGraphModel`` + ``BasicGraphicsScene`` + - Use ``DataFlowGraphModel`` + ``DataFlowGraphicsScene`` + * - You manage all graph data + - Library manages node delegates and data routing + * - Best for: visualization, custom graph logic + - Best for: visual programming, calculators, pipelines + +Getting Started +--------------- + +.. toctree:: + :maxdepth: 1 + + getting-started/installation + getting-started/quickstart + getting-started/concepts + +User Guide +---------- .. toctree:: - :maxdepth: 3 - - overview - features - porting - development - testing - classes - notes - license_link - -Index -================== + :maxdepth: 1 + + guide/graph-models + guide/data-flow + guide/visualization + guide/styling + guide/serialization + guide/undo-redo + guide/advanced + +Examples +-------- + +.. toctree:: + :maxdepth: 1 + + examples/index + +API Reference +------------- + +.. toctree:: + :maxdepth: 1 + + api/index + api/classes + +Other Resources +--------------- + +.. toctree:: + :maxdepth: 1 + + migration/v2-to-v3 + faq + testing + license_link + +Indices +------- * :ref:`genindex` diff --git a/docs/migration/v2-to-v3.rst b/docs/migration/v2-to-v3.rst new file mode 100644 index 000000000..c67fd4ee8 --- /dev/null +++ b/docs/migration/v2-to-v3.rst @@ -0,0 +1,230 @@ +Migrating from v2.x to v3.x +=========================== + +Version 3.0 introduced a Model-View architecture. This guide helps you +migrate existing code. + +Overview of Changes +------------------- + +**Architecture shift:** + +- v2: ``Node`` and ``Connection`` objects held their own data +- v3: ``AbstractGraphModel`` holds all data; graphics objects are just views + +**Key benefits of v3:** + +- Headless operation (no GUI required) +- Multiple views of same data +- Better separation of concerns +- Easier testing + +Class Renames +------------- + +.. list-table:: + :widths: 25 25 50 + :header-rows: 1 + + * - v2.x Class + - v3.x Class + - Notes + * - ``Node`` + - (removed) + - Nodes are now just ``NodeId`` values + * - ``Connection`` + - (removed) + - Connections are now ``ConnectionId`` structs + * - ``NodeDataModel`` + - ``NodeDelegateModel`` + - Per-node logic is now a "delegate" + * - ``DataModelRegistry`` + - ``NodeDelegateModelRegistry`` + - Consistent naming + * - ``FlowView`` + - ``GraphicsView`` + - Simpler name + * - ``FlowScene`` + - ``DataFlowGraphicsScene`` + - Inherits from ``BasicGraphicsScene`` + * - ``NodePainter`` + - ``DefaultNodePainter`` + - Now inherits ``AbstractNodePainter`` + * - (new) + - ``AbstractGraphModel`` + - Central data store + * - (new) + - ``DataFlowGraphModel`` + - Model with data propagation + +Migration Steps +--------------- + +**Step 1: Replace FlowScene/FlowView** + +Before (v2): + +.. code-block:: cpp + + FlowScene* scene = new FlowScene(registry); + FlowView* view = new FlowView(scene); + +After (v3): + +.. code-block:: cpp + + DataFlowGraphModel model(registry); + DataFlowGraphicsScene* scene = new DataFlowGraphicsScene(model); + GraphicsView* view = new GraphicsView(scene); + +**Step 2: Rename NodeDataModel to NodeDelegateModel** + +Before: + +.. code-block:: cpp + + class MyNode : public NodeDataModel { ... }; + +After: + +.. code-block:: cpp + + class MyNode : public NodeDelegateModel { ... }; + +**Step 3: Update registry usage** + +Before: + +.. code-block:: cpp + + auto registry = std::make_shared(); + registry->registerModel(); + +After: + +.. code-block:: cpp + + auto registry = std::make_shared(); + registry->registerModel("Category"); // Category now required + +**Step 4: Update node access** + +Before (v2 - accessing Node objects): + +.. code-block:: cpp + + Node& node = scene->createNode(...); + node.nodeDataModel()->setData(...); + +After (v3 - accessing through model): + +.. code-block:: cpp + + NodeId nodeId = model.addNode("MyNode"); + auto* delegate = model.delegateModel(nodeId); + delegate->setData(...); + +API Changes in NodeDelegateModel +-------------------------------- + +Most methods remain the same: + +- ``name()`` -- unchanged +- ``caption()`` -- unchanged +- ``nPorts()`` -- unchanged +- ``dataType()`` -- unchanged +- ``setInData()`` -- unchanged +- ``outData()`` -- unchanged +- ``embeddedWidget()`` -- unchanged + +**New methods:** + +- ``validationState()`` -- Returns current validation state +- ``processingStatus()`` -- Returns processing status +- ``setValidationState()`` -- Set validation state +- ``setNodeProcessingStatus()`` -- Set processing status + +Removed Features +---------------- + +These v2 features were removed in v3: + +**Warning messages at node bottom** + +v2 displayed validation messages below nodes. In v3, use ``NodeValidationState`` +instead, which shows icons and tooltips. + +**Data Type Converters** + +v2 had automatic type converters. In v3, handle type compatibility in +``connectionPossible()`` or convert data in your delegate's ``setInData()``. + +Connection Changes +------------------ + +Before (v2): + +.. code-block:: cpp + + // Connections were objects + Connection* conn = scene->createConnection(...); + +After (v3): + +.. code-block:: cpp + + // Connections are just IDs + ConnectionId conn{outNodeId, outPortIndex, inNodeId, inPortIndex}; + model.addConnection(conn); + +Serialization Changes +--------------------- + +The JSON format is compatible, but the API changed: + +Before (v2): + +.. code-block:: cpp + + scene->save(); // QByteArray + scene->load(); // from file dialog + +After (v3): + +.. code-block:: cpp + + QJsonObject json = model.save(); + model.load(json); + + // Or use scene helpers: + scene.save(); // Opens file dialog + scene.load(); // Opens file dialog + +Custom Painters +--------------- + +Before (v2): + +.. code-block:: cpp + + // NodePainter was created on the stack during paint + // No easy way to customize + +After (v3): + +.. code-block:: cpp + + class MyPainter : public AbstractNodePainter { + void paint(QPainter*, NodeGraphicsObject&) const override; + }; + + scene.setNodePainter(std::make_unique()); + +Getting Help +------------ + +If you encounter migration issues: + +1. Check the examples in ``examples/`` -- they all use v3 API +2. See the :doc:`/guide/data-flow` guide for data flow patterns +3. Open an issue on `GitHub `_ diff --git a/docs/notes.rst b/docs/notes.rst deleted file mode 100644 index b95001df4..000000000 --- a/docs/notes.rst +++ /dev/null @@ -1,49 +0,0 @@ -Random not Categorized Notes -============================ - -Node Geometry -------------- - -.. code-block:: - - spacing spacing - spacing / \ spacing - \ port port / - | | width | | | | width | | - 0 - 0_|_________________________________________ ___ - / \ ___ spacing - | | - | Caption | caption height - | ________________ | ___ - | | | | ___ spacing - | | | | - O In Name | | Out Name O entry - | | | | ___ - | | | | ___ spacing - | | | | - O Another In | | Out Name O - | | | | - | | | | - | | | | - O | | O - | | | | - | |_______________| | - | | - O | - | | - \_________________________________________/ - - - - -Node's size must be recalculated in following cases: - - #. After construction. - #. Embedding the widget. - #. After resizing. - #. Before painting (conditional, depends on whether the font metrics was changed). - #. When incoming data changed (could trigger size changes, maybe in captions). - #. When embedded widget changes its size. - - diff --git a/docs/overview.rst b/docs/overview.rst deleted file mode 100644 index 29c7eb58d..000000000 --- a/docs/overview.rst +++ /dev/null @@ -1,64 +0,0 @@ -Overview -======== - -Intro ------ - - -QtNodes is a Qt-based library designed for graphical representation of -the node graphs and performing various operations on them. - -.. image:: /_static/calculator.png - -The project is built with help of the CMake configurator. Therefore it -is quite easy to incorporate the library into any CMake-based Qt -project. - -As of version `3.0` the library uses the Model-View approach. The -central class :cpp:type:`AbstractGraphModel` is a starting point for user graph -models. It wraps the data representing the graph and forwards it -to the `BasicGraphicsScene` -- a class responsible for populating -`QGraphicsObject` items and showing them on the `QGraphicsView` widget. - -The library could be used for two purposes: - - 1. General-purpose graph visulalization and editing. - 2. Computing data in the nodes and propagating it through connections. - -The "headless" mode is also supported. It is possible to create, delete, connect -and disconnect nodes, as well as propagate data, without assigning your -:cpp:type:`AbstractGraphModel` derivative to a :cpp:type:`BasicGraphicsScene`. - -Examples Directory Layout -------------------------- - -The examples could be found in the directory ``examples``: - -- ``graph``. Demonstrates usage of AbstractGraphModel for general - graph visulalization and editing. -- ``dynamic_ports``. Shows what needs to be done to dynamically create and - destroy node ports. -- ``lock_nodes_and_connections``. Demonstrates two capabilities of - "non-detachable" connectinos and "locked" nodes (non-movable, non-selectable). -- legacy "data flow" examples from versions prior to ``3.0``: - - ``text``. Text is propagated between the nodes. - - ``calculator/main.cpp``. Dataflow-based implementation of the simplest - calculator. We use an advanced model :cpp:type:`QtNodes::DataFlowGraphModel` - capable of storing the registry of NodeDataModel and propagating user data - beween the nodes. - - ``calculator/headless_main.cpp``. The example loads a scene saved by a - GUI-based ``calculator`` example and computes several results without - creating GUI elements. - - ``connection_colors``. Demonstrates the ability to color the - connections in correspondence to the connected data types. - - ``resizable_images``. The examples shows how to embed a widget into nodes and - how to make the nodes resizable. - - ``styles``. The example demonstrates graph style customization. - - - -Feedback Wanted ---------------- - -Make a request on `Github `_ if -something is unclear in the code or in the documentation. diff --git a/docs/porting.rst b/docs/porting.rst deleted file mode 100644 index 35f7aa02d..000000000 --- a/docs/porting.rst +++ /dev/null @@ -1,73 +0,0 @@ -Porting Code from Version 2.x -============================= - - -Renamed Classes ---------------- - -The majority of classes work without significant changes, you might need to -rename some base clasess you are inheriting from. - - -.. table:: - :widths: 10 10 30 - - ==================== ========================== ============== - Classes in v2 Classes in v3 Comment - ==================== ========================== ============== - Node -- The node is represented now just by its - internal id which is stored in a central - graph class. - - Connection -- Ditto - - NodeDataModel NodeDelegateModel In new terms a single model defines - the whole graph structure. Hence - smaller per-node models became - delegates. - - DataModelRegistry NodeDelegateModelRegistry See comment above - - FlowView GraphicsView - - FlowScene DataFlowGraphicsScene The new class inherits from - ``BasicGraphicsScene`` - - -- DataFlowGraphModel This is a new central class - that defines the whole graph structure. - The class takes - ``NodeDelegatNodeDelegateModelRegistry`` - in the constructur and - populates delegate models - internally. The graph model - itself is then passed to the - ``DataFlowGraphicsScene``. - - NodePainter DefaultNodePainter Previously a ``NodePainter`` - was created dynamically on - the stack right in the - painting routine. Now - painter is a class instance - constanly living in the - scene. It could be replaced - by a user-defined clas - inherited from - ``AbstractNodePainter`` - ==================== ========================== ============== - - -Removed Features ----------------- - - -Some minor capabilities were removed in version 3: - -- Warning messages at the bottom of the nodes. They were shown when the data was - incosistent or upon any other error signalized by a node. - The feature was useful in some cases but wasn't visually appealing and caused a - node resize/repainting events. -- Data Type Converters. Such classes were registered among Node Data Models and - made ports of different types compatible. I prefer to leave it up to the - ``AbstractGraphModel`` derivative to decide what could be attached and what - not. See the function ``AbstractGraphModel::connectionPossible``. - diff --git a/docs/testing.rst b/docs/testing.rst index d3085807e..6075759a5 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -81,7 +81,7 @@ Tests are organized in ``test/`` directory: ├── include/ │ ├── ApplicationSetup.hpp # Qt app setup │ ├── TestGraphModel.hpp # Test graph model - │ └── StubNodeDataModel.hpp # Node delegate stub + │ └── TestDataFlowNodes.hpp # Node delegate stub └── src/ ├── TestAbstractGraphModel.cpp ├── TestAbstractGraphModelSignals.cpp @@ -89,9 +89,14 @@ Tests are organized in ``test/`` directory: ├── TestNodeDelegateModelRegistry.cpp ├── TestBasicGraphicsScene.cpp ├── TestConnectionId.cpp + ├── TestCopyPaste.cpp + ├── TestCustomPainters.cpp + ├── TestLoopDetection.cpp + ├── TestNodeValidation.cpp ├── TestSerialization.cpp ├── TestUIInteraction.cpp - └── TestUndoCommands.cpp + ├── TestUndoCommands.cpp + └── TestZoomFeatures.cpp Test Categories --------------- @@ -146,6 +151,36 @@ Test Categories - Stress testing with rapid mouse movements and memory load - Virtual display testing with proper window exposure handling +**Validation Tests ([validation])** + - NodeValidationState struct functionality + - NodeProcessingStatus enum values + - Setting validation and processing status through NodeDelegateModel + - Integration with DataFlowGraphModel + +**Custom Painters Tests ([painters])** + - Custom node painter registration + - Custom connection painter registration + - Painter replacement behavior + - Paint method invocation verification + +**Copy/Paste Tests ([copypaste])** + - Single node copy + - Multiple node copy with connections + - Paste operations + - Duplicate operations (Ctrl+D) + +**Zoom Tests ([zoom])** + - Scale range configuration + - zoomFitAll behavior + - zoomFitSelected behavior + - scaleChanged signal emission + +**Loop Detection Tests ([loops])** + - loopsEnabled configuration in AbstractGraphModel + - DataFlowGraphModel loop prevention + - Direct self-loop prevention + - Indirect cycle detection + Key Features ------------ diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 49494da2e..04ac53ad3 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -16,3 +16,6 @@ add_subdirectory(dynamic_ports) add_subdirectory(lock_nodes_and_connections) +add_subdirectory(node_validation) + +add_subdirectory(custom_painter) diff --git a/examples/calculator/CMakeLists.txt b/examples/calculator/CMakeLists.txt index 60d2d14d7..dae6ee3b4 100644 --- a/examples/calculator/CMakeLists.txt +++ b/examples/calculator/CMakeLists.txt @@ -17,7 +17,7 @@ set(CALC_HEADER_FILES add_executable(calculator ${CALC_SOURCE_FILES} - ${CALC_HEAEDR_FILES} + ${CALC_HEADER_FILES} ) target_link_libraries(calculator QtNodes) @@ -33,7 +33,7 @@ set(HEADLESS_CALC_SOURCE_FILES add_executable(headless_calculator ${HEADLESS_CALC_SOURCE_FILES} - ${CALC_HEAEDR_FILES} + ${CALC_HEADER_FILES} ) target_link_libraries(headless_calculator QtNodes) diff --git a/examples/calculator/LongProcessingRandomNumber.hpp b/examples/calculator/LongProcessingRandomNumber.hpp index d64f4ba7b..7977adbb4 100644 --- a/examples/calculator/LongProcessingRandomNumber.hpp +++ b/examples/calculator/LongProcessingRandomNumber.hpp @@ -15,22 +15,19 @@ class RandomNumberModel : public MathOperationDataModel { public: - RandomNumberModel() { + RandomNumberModel() + : _timer(new QTimer(this)) + { this->setNodeProcessingStatus(QtNodes::NodeProcessingStatus::Empty); - QObject::connect(this, &NodeDelegateModel::computingStarted, this, [this]() { if (_number1.lock() && _number2.lock()) { - this->setNodeProcessingStatus( - QtNodes::NodeProcessingStatus::Processing); + this->setNodeProcessingStatus(QtNodes::NodeProcessingStatus::Processing); } - emit requestNodeUpdate(); }); QObject::connect(this, &NodeDelegateModel::computingFinished, this, [this]() { - this->setNodeProcessingStatus( - QtNodes::NodeProcessingStatus::Updated); - + this->setNodeProcessingStatus(QtNodes::NodeProcessingStatus::Updated); emit requestNodeUpdate(); }); } @@ -44,31 +41,34 @@ class RandomNumberModel : public MathOperationDataModel private: void compute() override { + // Stop any previous computation + _timer->stop(); + _timer->disconnect(); + Q_EMIT computingStarted(); PortIndex const outPortIndex = 0; auto n1 = _number1.lock(); auto n2 = _number2.lock(); - QTimer *timer = new QTimer(this); - timer->start(1000); - int secondsRemaining = 3; - connect(timer, &QTimer::timeout, this, [=]() mutable { - if (--secondsRemaining <= 0) { - timer->stop(); + _secondsRemaining = 3; + _timer->start(1000); + connect(_timer, &QTimer::timeout, this, [this, n1, n2, outPortIndex]() { + if (--_secondsRemaining <= 0) { + _timer->stop(); if (n1 && n2) { double a = n1->number(); double b = n2->number(); if (a > b) { setNodeProcessingStatus(QtNodes::NodeProcessingStatus::Failed); - emit requestNodeUpdate(); return; } double upper = std::nextafter(b, std::numeric_limits::max()); - double randomValue = QRandomGenerator::global()->generateDouble() * (upper - a) + a; + double randomValue = QRandomGenerator::global()->generateDouble() * (upper - a) + + a; _result = std::make_shared(randomValue); Q_EMIT computingFinished(); @@ -80,4 +80,8 @@ class RandomNumberModel : public MathOperationDataModel } }); } + +private: + QTimer *_timer; + int _secondsRemaining = 0; }; diff --git a/examples/calculator/main.cpp b/examples/calculator/main.cpp index 4d2504f02..f5a511796 100644 --- a/examples/calculator/main.cpp +++ b/examples/calculator/main.cpp @@ -112,6 +112,11 @@ int main(int argc, char *argv[]) mainWidget.setWindowModified(true); }); + if (scene->groupingEnabled()) { + auto loadGroupAction = menu->addAction("Load Group..."); + QObject::connect(loadGroupAction, &QAction::triggered, [scene] { scene->loadGroupFile(); }); + } + mainWidget.setWindowTitle("[*]Data Flow: simplest calculator"); mainWidget.resize(800, 600); // Center window. diff --git a/examples/custom_painter/CMakeLists.txt b/examples/custom_painter/CMakeLists.txt new file mode 100644 index 000000000..2fa035501 --- /dev/null +++ b/examples/custom_painter/CMakeLists.txt @@ -0,0 +1,6 @@ +file(GLOB_RECURSE CPPS ./*.cpp ) +file(GLOB_RECURSE HPPS ./*.hpp ) + +add_executable(custom_painter ${CPPS} ${HPPS}) + +target_link_libraries(custom_painter QtNodes) diff --git a/examples/custom_painter/CustomConnectionPainter.cpp b/examples/custom_painter/CustomConnectionPainter.cpp new file mode 100644 index 000000000..f30170872 --- /dev/null +++ b/examples/custom_painter/CustomConnectionPainter.cpp @@ -0,0 +1,127 @@ +#include "CustomConnectionPainter.hpp" + +#include +#include + +#include + +using QtNodes::ConnectionId; + +void CustomConnectionPainter::paint(QPainter *painter, ConnectionGraphicsObject const &cgo) const +{ + painter->setRenderHint(QPainter::Antialiasing); + + QPainterPath path = createPath(cgo); + + // Draw the connection with a custom style + QPen pen; + + if (cgo.isSelected()) { + pen.setColor(QColor(255, 215, 0)); // Gold when selected + pen.setWidth(4); + pen.setStyle(Qt::SolidLine); + } else if (cgo.connectionId().inNodeId == QtNodes::InvalidNodeId + || cgo.connectionId().outNodeId == QtNodes::InvalidNodeId) { + // Draft connection (being drawn) + pen.setColor(QColor(150, 150, 150)); + pen.setWidth(2); + pen.setStyle(Qt::DashLine); + } else { + // Normal connection - gradient-like effect using dashed line + pen.setColor(QColor(0, 191, 255)); // Deep sky blue + pen.setWidth(3); + pen.setStyle(Qt::SolidLine); + } + + painter->setPen(pen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(path); + + // Draw arrow at the end point (input port) + if (cgo.connectionId().inNodeId != QtNodes::InvalidNodeId) { + QPointF endPoint = cgo.endPoint(QtNodes::PortType::In); + QPointF startPoint = cgo.endPoint(QtNodes::PortType::Out); + + // Get a point slightly before the end for arrow direction + qreal t = 0.95; + QPointF controlPoint1 = startPoint + QPointF(50, 0); + QPointF controlPoint2 = endPoint - QPointF(50, 0); + + // Calculate point on bezier curve at t + qreal mt = 1.0 - t; + QPointF beforeEnd = mt * mt * mt * startPoint + 3.0 * mt * mt * t * controlPoint1 + + 3.0 * mt * t * t * controlPoint2 + t * t * t * endPoint; + + drawArrow(painter, endPoint, beforeEnd); + } +} + +QPainterPath CustomConnectionPainter::getPainterStroke(ConnectionGraphicsObject const &cgo) const +{ + QPainterPath path = createPath(cgo); + + QPainterPathStroker stroker; + stroker.setWidth(10.0); + return stroker.createStroke(path); +} + +QPainterPath CustomConnectionPainter::createPath(ConnectionGraphicsObject const &cgo) const +{ + QPointF const &startPoint = cgo.endPoint(QtNodes::PortType::Out); + QPointF const &endPoint = cgo.endPoint(QtNodes::PortType::In); + + // Create a bezier curve + QPainterPath path; + path.moveTo(startPoint); + + // Control points for smooth curve + qreal dx = std::abs(endPoint.x() - startPoint.x()); + qreal controlOffset = std::max(dx * 0.5, 30.0); + + QPointF controlPoint1(startPoint.x() + controlOffset, startPoint.y()); + QPointF controlPoint2(endPoint.x() - controlOffset, endPoint.y()); + + path.cubicTo(controlPoint1, controlPoint2, endPoint); + + return path; +} + +void CustomConnectionPainter::drawArrow(QPainter *painter, QPointF const &tip, QPointF const &from) const +{ + // Calculate arrow direction + QPointF direction = tip - from; + qreal length = std::sqrt(direction.x() * direction.x() + direction.y() * direction.y()); + + if (length < 1.0) + return; + + direction /= length; + + // Arrow parameters + qreal arrowSize = 10.0; + qreal arrowAngle = M_PI / 6.0; // 30 degrees + + // Calculate arrow points + QPointF arrowP1 = tip + - arrowSize + * QPointF(direction.x() * std::cos(arrowAngle) + - direction.y() * std::sin(arrowAngle), + direction.x() * std::sin(arrowAngle) + + direction.y() * std::cos(arrowAngle)); + + QPointF arrowP2 = tip + - arrowSize + * QPointF(direction.x() * std::cos(-arrowAngle) + - direction.y() * std::sin(-arrowAngle), + direction.x() * std::sin(-arrowAngle) + + direction.y() * std::cos(-arrowAngle)); + + // Draw filled arrow + QPainterPath arrowPath; + arrowPath.moveTo(tip); + arrowPath.lineTo(arrowP1); + arrowPath.lineTo(arrowP2); + arrowPath.closeSubpath(); + + painter->fillPath(arrowPath, painter->pen().color()); +} diff --git a/examples/custom_painter/CustomConnectionPainter.hpp b/examples/custom_painter/CustomConnectionPainter.hpp new file mode 100644 index 000000000..26b46316a --- /dev/null +++ b/examples/custom_painter/CustomConnectionPainter.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +#include +#include + +namespace QtNodes { +class ConnectionGraphicsObject; +} + +using QtNodes::AbstractConnectionPainter; +using QtNodes::ConnectionGraphicsObject; + +/// Custom connection painter that draws dashed lines with arrows +class CustomConnectionPainter : public AbstractConnectionPainter +{ +public: + void paint(QPainter *painter, ConnectionGraphicsObject const &cgo) const override; + QPainterPath getPainterStroke(ConnectionGraphicsObject const &cgo) const override; + +private: + QPainterPath createPath(ConnectionGraphicsObject const &cgo) const; + void drawArrow(QPainter *painter, QPointF const &tip, QPointF const &from) const; +}; diff --git a/examples/custom_painter/CustomNodePainter.cpp b/examples/custom_painter/CustomNodePainter.cpp new file mode 100644 index 000000000..ba55effde --- /dev/null +++ b/examples/custom_painter/CustomNodePainter.cpp @@ -0,0 +1,105 @@ +#include "CustomNodePainter.hpp" + +#include +#include +#include +#include + +#include + +using QtNodes::AbstractGraphModel; +using QtNodes::AbstractNodeGeometry; +using QtNodes::BasicGraphicsScene; +using QtNodes::NodeRole; +using QtNodes::PortType; +using QtNodes::StyleCollection; + +void CustomNodePainter::paint(QPainter *painter, NodeGraphicsObject &ngo) const +{ + painter->setRenderHint(QPainter::Antialiasing); + + drawBackground(painter, ngo); + drawCaption(painter, ngo); + drawPorts(painter, ngo); +} + +void CustomNodePainter::drawBackground(QPainter *painter, NodeGraphicsObject &ngo) const +{ + AbstractNodeGeometry const &geometry = ngo.nodeScene()->nodeGeometry(); + AbstractGraphModel const &model = ngo.graphModel(); + QtNodes::NodeId const nodeId = ngo.nodeId(); + + QRectF const boundingRect = geometry.boundingRect(nodeId); + + // Custom rounded rectangle with gradient + double const radius = 15.0; + + QPainterPath path; + path.addRoundedRect(boundingRect, radius, radius); + + // Create a custom gradient - blue to purple + QLinearGradient gradient(boundingRect.topLeft(), boundingRect.bottomRight()); + + if (ngo.isSelected()) { + gradient.setColorAt(0.0, QColor(100, 149, 237)); // Cornflower blue + gradient.setColorAt(1.0, QColor(186, 85, 211)); // Medium orchid + } else { + gradient.setColorAt(0.0, QColor(70, 130, 180)); // Steel blue + gradient.setColorAt(1.0, QColor(138, 43, 226)); // Blue violet + } + + painter->fillPath(path, gradient); + + // Draw border + QPen pen(ngo.isSelected() ? QColor(255, 215, 0) : QColor(255, 255, 255), 2.0); + painter->setPen(pen); + painter->drawPath(path); +} + +void CustomNodePainter::drawCaption(QPainter *painter, NodeGraphicsObject &ngo) const +{ + AbstractNodeGeometry const &geometry = ngo.nodeScene()->nodeGeometry(); + AbstractGraphModel const &model = ngo.graphModel(); + QtNodes::NodeId const nodeId = ngo.nodeId(); + + QString const caption = model.nodeData(nodeId, NodeRole::Caption).toString(); + QRectF const boundingRect = geometry.boundingRect(nodeId); + + // Draw caption centered at top + QFont font = painter->font(); + font.setBold(true); + font.setPointSize(12); + painter->setFont(font); + + painter->setPen(Qt::white); + + QRectF captionRect = boundingRect; + captionRect.setHeight(30); + + painter->drawText(captionRect, Qt::AlignCenter, caption); +} + +void CustomNodePainter::drawPorts(QPainter *painter, NodeGraphicsObject &ngo) const +{ + AbstractNodeGeometry const &geometry = ngo.nodeScene()->nodeGeometry(); + AbstractGraphModel const &model = ngo.graphModel(); + QtNodes::NodeId const nodeId = ngo.nodeId(); + + // Draw input ports (left side) - green circles + unsigned int const inPortCount = model.nodeData(nodeId, NodeRole::InPortCount); + for (unsigned int i = 0; i < inPortCount; ++i) { + QPointF pos = geometry.portPosition(nodeId, PortType::In, i); + painter->setBrush(QColor(50, 205, 50)); // Lime green + painter->setPen(QPen(Qt::white, 1.5)); + painter->drawEllipse(pos, 6.0, 6.0); + } + + // Draw output ports (right side) - orange circles + unsigned int const outPortCount = model.nodeData(nodeId, NodeRole::OutPortCount); + for (unsigned int i = 0; i < outPortCount; ++i) { + QPointF pos = geometry.portPosition(nodeId, PortType::Out, i); + painter->setBrush(QColor(255, 165, 0)); // Orange + painter->setPen(QPen(Qt::white, 1.5)); + painter->drawEllipse(pos, 6.0, 6.0); + } +} diff --git a/examples/custom_painter/CustomNodePainter.hpp b/examples/custom_painter/CustomNodePainter.hpp new file mode 100644 index 000000000..0a1be8b89 --- /dev/null +++ b/examples/custom_painter/CustomNodePainter.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include + +namespace QtNodes { +class NodeGraphicsObject; +class AbstractNodeGeometry; +class AbstractGraphModel; +} // namespace QtNodes + +using QtNodes::AbstractNodePainter; +using QtNodes::NodeGraphicsObject; + +/// Custom node painter that draws nodes with rounded corners and a gradient +class CustomNodePainter : public AbstractNodePainter +{ +public: + void paint(QPainter *painter, NodeGraphicsObject &ngo) const override; + +private: + void drawBackground(QPainter *painter, NodeGraphicsObject &ngo) const; + void drawCaption(QPainter *painter, NodeGraphicsObject &ngo) const; + void drawPorts(QPainter *painter, NodeGraphicsObject &ngo) const; +}; diff --git a/examples/custom_painter/SimpleGraphModel.cpp b/examples/custom_painter/SimpleGraphModel.cpp new file mode 100644 index 000000000..760cea69c --- /dev/null +++ b/examples/custom_painter/SimpleGraphModel.cpp @@ -0,0 +1,256 @@ +#include "SimpleGraphModel.hpp" + +SimpleGraphModel::SimpleGraphModel() + : _nextNodeId{0} +{} + +SimpleGraphModel::~SimpleGraphModel() {} + +std::unordered_set SimpleGraphModel::allNodeIds() const +{ + return _nodeIds; +} + +std::unordered_set SimpleGraphModel::allConnectionIds(NodeId const nodeId) const +{ + std::unordered_set result; + + std::copy_if(_connectivity.begin(), + _connectivity.end(), + std::inserter(result, std::end(result)), + [&nodeId](ConnectionId const &cid) { + return cid.inNodeId == nodeId || cid.outNodeId == nodeId; + }); + + return result; +} + +std::unordered_set SimpleGraphModel::connections(NodeId nodeId, + PortType portType, + PortIndex portIndex) const +{ + std::unordered_set result; + + std::copy_if(_connectivity.begin(), + _connectivity.end(), + std::inserter(result, std::end(result)), + [&portType, &portIndex, &nodeId](ConnectionId const &cid) { + return (getNodeId(portType, cid) == nodeId + && getPortIndex(portType, cid) == portIndex); + }); + + return result; +} + +bool SimpleGraphModel::connectionExists(ConnectionId const connectionId) const +{ + return (_connectivity.find(connectionId) != _connectivity.end()); +} + +NodeId SimpleGraphModel::addNode(QString const nodeType) +{ + NodeId newId = newNodeId(); + _nodeIds.insert(newId); + + Q_EMIT nodeCreated(newId); + + return newId; +} + +bool SimpleGraphModel::connectionPossible(ConnectionId const connectionId) const +{ + return _connectivity.find(connectionId) == _connectivity.end(); +} + +void SimpleGraphModel::addConnection(ConnectionId const connectionId) +{ + _connectivity.insert(connectionId); + + Q_EMIT connectionCreated(connectionId); +} + +bool SimpleGraphModel::nodeExists(NodeId const nodeId) const +{ + return (_nodeIds.find(nodeId) != _nodeIds.end()); +} + +QVariant SimpleGraphModel::nodeData(NodeId nodeId, NodeRole role) const +{ + QVariant result; + + switch (role) { + case NodeRole::Type: + result = QString("Custom Painted Node"); + break; + + case NodeRole::Position: + result = _nodeGeometryData[nodeId].pos; + break; + + case NodeRole::Size: + result = _nodeGeometryData[nodeId].size; + break; + + case NodeRole::CaptionVisible: + result = true; + break; + + case NodeRole::Caption: + result = QString("Node %1").arg(nodeId); + break; + + case NodeRole::Style: { + auto style = StyleCollection::nodeStyle(); + result = style.toJson().toVariantMap(); + } break; + + case NodeRole::InternalData: + break; + + case NodeRole::InPortCount: + result = 2u; + break; + + case NodeRole::OutPortCount: + result = 2u; + break; + + case NodeRole::Widget: + result = QVariant(); + break; + + default: + break; + } + + return result; +} + +bool SimpleGraphModel::setNodeData(NodeId nodeId, NodeRole role, QVariant value) +{ + bool result = false; + + switch (role) { + case NodeRole::Position: { + _nodeGeometryData[nodeId].pos = value.value(); + Q_EMIT nodePositionUpdated(nodeId); + result = true; + } break; + + case NodeRole::Size: { + _nodeGeometryData[nodeId].size = value.value(); + result = true; + } break; + + default: + break; + } + + return result; +} + +QVariant SimpleGraphModel::portData(NodeId nodeId, + PortType portType, + PortIndex portIndex, + PortRole role) const +{ + switch (role) { + case PortRole::Data: + return QVariant(); + + case PortRole::DataType: + return QVariant(); + + case PortRole::ConnectionPolicyRole: + return QVariant::fromValue(ConnectionPolicy::One); + + case PortRole::CaptionVisible: + return false; + + case PortRole::Caption: + return QString(); + } + + return QVariant(); +} + +bool SimpleGraphModel::setPortData( + NodeId nodeId, PortType portType, PortIndex portIndex, QVariant const &value, PortRole role) +{ + Q_UNUSED(nodeId); + Q_UNUSED(portType); + Q_UNUSED(portIndex); + Q_UNUSED(value); + Q_UNUSED(role); + + return false; +} + +bool SimpleGraphModel::deleteConnection(ConnectionId const connectionId) +{ + bool disconnected = false; + + auto it = _connectivity.find(connectionId); + + if (it != _connectivity.end()) { + disconnected = true; + _connectivity.erase(it); + } + + if (disconnected) + Q_EMIT connectionDeleted(connectionId); + + return disconnected; +} + +bool SimpleGraphModel::deleteNode(NodeId const nodeId) +{ + auto connectionIds = allConnectionIds(nodeId); + + for (auto &cId : connectionIds) { + deleteConnection(cId); + } + + _nodeIds.erase(nodeId); + _nodeGeometryData.erase(nodeId); + + Q_EMIT nodeDeleted(nodeId); + + return true; +} + +QJsonObject SimpleGraphModel::saveNode(NodeId const nodeId) const +{ + QJsonObject nodeJson; + + nodeJson["id"] = static_cast(nodeId); + + { + QPointF const pos = nodeData(nodeId, NodeRole::Position).value(); + + QJsonObject posJson; + posJson["x"] = pos.x(); + posJson["y"] = pos.y(); + nodeJson["position"] = posJson; + } + + return nodeJson; +} + +void SimpleGraphModel::loadNode(QJsonObject const &nodeJson) +{ + NodeId restoredNodeId = static_cast(nodeJson["id"].toInt()); + + _nextNodeId = std::max(_nextNodeId, restoredNodeId + 1); + + _nodeIds.insert(restoredNodeId); + + Q_EMIT nodeCreated(restoredNodeId); + + { + QJsonObject posJson = nodeJson["position"].toObject(); + QPointF const pos(posJson["x"].toDouble(), posJson["y"].toDouble()); + + setNodeData(restoredNodeId, NodeRole::Position, pos); + } +} diff --git a/examples/custom_painter/SimpleGraphModel.hpp b/examples/custom_painter/SimpleGraphModel.hpp new file mode 100644 index 000000000..fccf99cbb --- /dev/null +++ b/examples/custom_painter/SimpleGraphModel.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +using ConnectionId = QtNodes::ConnectionId; +using ConnectionPolicy = QtNodes::ConnectionPolicy; +using NodeFlag = QtNodes::NodeFlag; +using NodeId = QtNodes::NodeId; +using NodeRole = QtNodes::NodeRole; +using PortIndex = QtNodes::PortIndex; +using PortRole = QtNodes::PortRole; +using PortType = QtNodes::PortType; +using StyleCollection = QtNodes::StyleCollection; +using QtNodes::InvalidNodeId; + +/// Simple graph model for demonstrating custom painters +class SimpleGraphModel : public QtNodes::AbstractGraphModel +{ + Q_OBJECT +public: + struct NodeGeometryData + { + QSize size; + QPointF pos; + }; + +public: + SimpleGraphModel(); + + ~SimpleGraphModel() override; + + std::unordered_set allNodeIds() const override; + + std::unordered_set allConnectionIds(NodeId const nodeId) const override; + + std::unordered_set connections(NodeId nodeId, + PortType portType, + PortIndex portIndex) const override; + + bool connectionExists(ConnectionId const connectionId) const override; + + NodeId addNode(QString const nodeType = QString()) override; + + bool connectionPossible(ConnectionId const connectionId) const override; + + void addConnection(ConnectionId const connectionId) override; + + bool nodeExists(NodeId const nodeId) const override; + + QVariant nodeData(NodeId nodeId, NodeRole role) const override; + + bool setNodeData(NodeId nodeId, NodeRole role, QVariant value) override; + + QVariant portData(NodeId nodeId, + PortType portType, + PortIndex portIndex, + PortRole role) const override; + + bool setPortData(NodeId nodeId, + PortType portType, + PortIndex portIndex, + QVariant const &value, + PortRole role = PortRole::Data) override; + + bool deleteConnection(ConnectionId const connectionId) override; + + bool deleteNode(NodeId const nodeId) override; + + QJsonObject saveNode(NodeId const) const override; + + void loadNode(QJsonObject const &nodeJson) override; + + NodeId newNodeId() override { return _nextNodeId++; } + +private: + std::unordered_set _nodeIds; + std::unordered_set _connectivity; + mutable std::unordered_map _nodeGeometryData; + NodeId _nextNodeId; +}; diff --git a/examples/custom_painter/main.cpp b/examples/custom_painter/main.cpp new file mode 100644 index 000000000..41de89c5c --- /dev/null +++ b/examples/custom_painter/main.cpp @@ -0,0 +1,71 @@ +#include +#include +#include + +#include +#include +#include + +#include "CustomConnectionPainter.hpp" +#include "CustomNodePainter.hpp" +#include "SimpleGraphModel.hpp" + +using QtNodes::BasicGraphicsScene; +using QtNodes::GraphicsView; +using QtNodes::NodeRole; + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + + SimpleGraphModel graphModel; + + // Initialize and connect some nodes + { + NodeId id1 = graphModel.addNode(); + graphModel.setNodeData(id1, NodeRole::Position, QPointF(0, 0)); + + NodeId id2 = graphModel.addNode(); + graphModel.setNodeData(id2, NodeRole::Position, QPointF(300, 50)); + + NodeId id3 = graphModel.addNode(); + graphModel.setNodeData(id3, NodeRole::Position, QPointF(300, 200)); + + NodeId id4 = graphModel.addNode(); + graphModel.setNodeData(id4, NodeRole::Position, QPointF(600, 100)); + + // Create some connections + graphModel.addConnection(ConnectionId{id1, 0, id2, 0}); + graphModel.addConnection(ConnectionId{id1, 1, id3, 0}); + graphModel.addConnection(ConnectionId{id2, 0, id4, 0}); + graphModel.addConnection(ConnectionId{id3, 0, id4, 1}); + } + + auto scene = new BasicGraphicsScene(graphModel); + + // Set custom painters + scene->setNodePainter(std::make_unique()); + scene->setConnectionPainter(std::make_unique()); + + GraphicsView view(scene); + + // Setup context menu for creating new nodes + view.setContextMenuPolicy(Qt::ActionsContextMenu); + QAction createNodeAction(QStringLiteral("Create Node"), &view); + QObject::connect(&createNodeAction, &QAction::triggered, [&]() { + QPointF posView = view.mapToScene(view.mapFromGlobal(QCursor::pos())); + + NodeId const newId = graphModel.addNode(); + graphModel.setNodeData(newId, NodeRole::Position, posView); + }); + view.insertAction(view.actions().front(), &createNodeAction); + + view.setWindowTitle("Custom Painter Example"); + view.resize(800, 600); + + // Center window + view.move(QApplication::primaryScreen()->availableGeometry().center() - view.rect().center()); + view.showNormal(); + + return app.exec(); +} diff --git a/examples/node_validation/CMakeLists.txt b/examples/node_validation/CMakeLists.txt new file mode 100644 index 000000000..c7f059ce3 --- /dev/null +++ b/examples/node_validation/CMakeLists.txt @@ -0,0 +1,6 @@ +file(GLOB_RECURSE CPPS ./*.cpp ) +file(GLOB_RECURSE HPPS ./*.hpp ) + +add_executable(node_validation ${CPPS} ${HPPS}) + +target_link_libraries(node_validation QtNodes) diff --git a/examples/node_validation/TextData.hpp b/examples/node_validation/TextData.hpp new file mode 100644 index 000000000..8528b4f0b --- /dev/null +++ b/examples/node_validation/TextData.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include + +using QtNodes::NodeData; +using QtNodes::NodeDataType; + +/// Simple text data class for transferring strings between nodes +class TextData : public NodeData +{ +public: + TextData() = default; + + TextData(QString const &text) + : _text(text) + {} + + NodeDataType type() const override { return NodeDataType{"text", "Text"}; } + + QString text() const { return _text; } + + bool isEmpty() const { return _text.isEmpty(); } + +private: + QString _text; +}; diff --git a/examples/node_validation/TextDisplayModel.hpp b/examples/node_validation/TextDisplayModel.hpp new file mode 100644 index 000000000..7b77c282d --- /dev/null +++ b/examples/node_validation/TextDisplayModel.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include + +#include +#include + +#include "TextData.hpp" + +using QtNodes::NodeData; +using QtNodes::NodeDataType; +using QtNodes::NodeDelegateModel; +using QtNodes::PortIndex; +using QtNodes::PortType; + +/// A simple text display node +class TextDisplayModel : public NodeDelegateModel +{ + Q_OBJECT + +public: + TextDisplayModel() + : _label(nullptr) + {} + + QString caption() const override { return QStringLiteral("Text Display"); } + + QString name() const override { return QStringLiteral("TextDisplay"); } + + unsigned int nPorts(PortType portType) const override + { + if (portType == PortType::In) + return 1; + return 0; + } + + NodeDataType dataType(PortType, PortIndex) const override + { + return TextData{}.type(); + } + + std::shared_ptr outData(PortIndex) override + { + return nullptr; + } + + void setInData(std::shared_ptr data, PortIndex) override + { + auto textData = std::dynamic_pointer_cast(data); + if (textData && !textData->isEmpty()) { + _label->setText(textData->text()); + } else { + _label->setText("No data"); + } + } + + QWidget *embeddedWidget() override + { + if (!_label) { + _label = new QLabel("No data"); + _label->setMinimumWidth(100); + _label->setAlignment(Qt::AlignCenter); + _label->setStyleSheet("QLabel { background-color: #333; padding: 5px; }"); + } + return _label; + } + +private: + QLabel *_label; +}; diff --git a/examples/node_validation/TextSourceModel.hpp b/examples/node_validation/TextSourceModel.hpp new file mode 100644 index 000000000..3adb5a850 --- /dev/null +++ b/examples/node_validation/TextSourceModel.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include + +#include +#include + +#include "TextData.hpp" + +using QtNodes::NodeData; +using QtNodes::NodeDataType; +using QtNodes::NodeDelegateModel; +using QtNodes::PortIndex; +using QtNodes::PortType; + +/// A simple text source node with an embedded line edit +class TextSourceModel : public NodeDelegateModel +{ + Q_OBJECT + +public: + TextSourceModel() + : _lineEdit(nullptr) + {} + + QString caption() const override { return QStringLiteral("Text Source"); } + + QString name() const override { return QStringLiteral("TextSource"); } + + unsigned int nPorts(PortType portType) const override + { + if (portType == PortType::Out) + return 1; + return 0; + } + + NodeDataType dataType(PortType, PortIndex) const override + { + return TextData{}.type(); + } + + std::shared_ptr outData(PortIndex) override + { + return _data; + } + + void setInData(std::shared_ptr, PortIndex) override {} + + QWidget *embeddedWidget() override + { + if (!_lineEdit) { + _lineEdit = new QLineEdit(); + _lineEdit->setPlaceholderText("Enter text..."); + connect(_lineEdit, &QLineEdit::textChanged, this, &TextSourceModel::onTextChanged); + } + return _lineEdit; + } + +private Q_SLOTS: + void onTextChanged(QString const &text) + { + _data = std::make_shared(text); + Q_EMIT dataUpdated(0); + } + +private: + std::shared_ptr _data; + QLineEdit *_lineEdit; +}; diff --git a/examples/node_validation/ValidatedModel.hpp b/examples/node_validation/ValidatedModel.hpp new file mode 100644 index 000000000..1c702fbcf --- /dev/null +++ b/examples/node_validation/ValidatedModel.hpp @@ -0,0 +1,158 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include "TextData.hpp" + +using QtNodes::NodeData; +using QtNodes::NodeDataType; +using QtNodes::NodeDelegateModel; +using QtNodes::NodeProcessingStatus; +using QtNodes::NodeValidationState; +using QtNodes::PortIndex; +using QtNodes::PortType; + +/// A node that demonstrates validation states and processing status. +/// It validates that input text has a minimum length and simulates processing. +class ValidatedModel : public NodeDelegateModel +{ + Q_OBJECT + +public: + ValidatedModel() + : _widget(nullptr) + , _label(nullptr) + , _minLength(3) + {} + + QString caption() const override { return QStringLiteral("Text Validator"); } + + QString name() const override { return QStringLiteral("TextValidator"); } + + unsigned int nPorts(PortType portType) const override + { + if (portType == PortType::In) + return 1; + if (portType == PortType::Out) + return 1; + return 0; + } + + NodeDataType dataType(PortType, PortIndex) const override + { + return TextData{}.type(); + } + + std::shared_ptr outData(PortIndex) override + { + return _outputData; + } + + void setInData(std::shared_ptr data, PortIndex) override + { + auto textData = std::dynamic_pointer_cast(data); + + if (!textData || textData->isEmpty()) { + // No input - set Empty status + setNodeProcessingStatus(NodeProcessingStatus::Empty); + NodeValidationState state; + state._state = NodeValidationState::State::Warning; + state._stateMessage = "No input data"; + setValidationState(state); + + _outputData.reset(); + if (_label) + _label->setText("Waiting for input..."); + + Q_EMIT dataInvalidated(0); + return; + } + + // We have input - start "processing" + setNodeProcessingStatus(NodeProcessingStatus::Processing); + if (_label) + _label->setText("Processing..."); + + // Simulate async processing with a timer + QTimer::singleShot(500, this, [this, textData]() { + processInput(textData); + }); + } + + QWidget *embeddedWidget() override + { + if (!_widget) { + _widget = new QWidget(); + auto layout = new QVBoxLayout(_widget); + layout->setContentsMargins(5, 5, 5, 5); + + _label = new QLabel("Waiting for input..."); + _label->setWordWrap(true); + _label->setMinimumWidth(120); + layout->addWidget(_label); + } + return _widget; + } + +private: + void processInput(std::shared_ptr textData) + { + QString text = textData->text(); + + if (text.length() < _minLength) { + // Validation failed + NodeValidationState state; + state._state = NodeValidationState::State::Error; + state._stateMessage = QString("Text must be at least %1 characters").arg(_minLength); + setValidationState(state); + + setNodeProcessingStatus(NodeProcessingStatus::Failed); + + _outputData.reset(); + if (_label) + _label->setText(QString("Error: too short\n(min %1 chars)").arg(_minLength)); + + Q_EMIT dataInvalidated(0); + } else if (text.length() < _minLength * 2) { + // Partial success - warning + NodeValidationState state; + state._state = NodeValidationState::State::Warning; + state._stateMessage = "Text is short but acceptable"; + setValidationState(state); + + setNodeProcessingStatus(NodeProcessingStatus::Partial); + + _outputData = std::make_shared(text.toUpper()); + if (_label) + _label->setText("Output: " + _outputData->text()); + + Q_EMIT dataUpdated(0); + } else { + // Full success + NodeValidationState state; + state._state = NodeValidationState::State::Valid; + state._stateMessage = ""; + setValidationState(state); + + setNodeProcessingStatus(NodeProcessingStatus::Updated); + + _outputData = std::make_shared(text.toUpper()); + if (_label) + _label->setText("Output: " + _outputData->text()); + + Q_EMIT dataUpdated(0); + } + } + +private: + std::shared_ptr _outputData; + QWidget *_widget; + QLabel *_label; + int _minLength; +}; diff --git a/examples/node_validation/main.cpp b/examples/node_validation/main.cpp new file mode 100644 index 000000000..ec8eb89a7 --- /dev/null +++ b/examples/node_validation/main.cpp @@ -0,0 +1,54 @@ +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "TextDisplayModel.hpp" +#include "TextSourceModel.hpp" +#include "ValidatedModel.hpp" + +using QtNodes::DataFlowGraphicsScene; +using QtNodes::DataFlowGraphModel; +using QtNodes::GraphicsView; +using QtNodes::NodeDelegateModelRegistry; + +static std::shared_ptr registerDataModels() +{ + auto ret = std::make_shared(); + + ret->registerModel("Sources"); + ret->registerModel("Processors"); + ret->registerModel("Displays"); + + return ret; +} + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + + std::shared_ptr registry = registerDataModels(); + + QWidget mainWidget; + QVBoxLayout *layout = new QVBoxLayout(&mainWidget); + layout->setContentsMargins(0, 0, 0, 0); + + DataFlowGraphModel dataFlowGraphModel(registry); + + auto scene = new DataFlowGraphicsScene(dataFlowGraphModel, &mainWidget); + auto view = new GraphicsView(scene); + layout->addWidget(view); + + mainWidget.setWindowTitle("Node Validation Example"); + mainWidget.resize(800, 600); + mainWidget.move(QApplication::primaryScreen()->availableGeometry().center() + - mainWidget.rect().center()); + mainWidget.showNormal(); + + return app.exec(); +} diff --git a/examples/styles/models.hpp b/examples/styles/models.hpp index 199f80bee..1352e4bfc 100644 --- a/examples/styles/models.hpp +++ b/examples/styles/models.hpp @@ -36,8 +36,6 @@ class MyDataModel : public NodeDelegateModel QString name() const override { return QString("MyDataModel"); } - bool labelEditable() const override { return true; } - public: QJsonObject save() const override { diff --git a/include/QtNodes/GroupGraphicsObject b/include/QtNodes/GroupGraphicsObject new file mode 100644 index 000000000..8461c673b --- /dev/null +++ b/include/QtNodes/GroupGraphicsObject @@ -0,0 +1 @@ +#include "internal/GroupGraphicsObject.hpp" diff --git a/include/QtNodes/NodeGroup b/include/QtNodes/NodeGroup new file mode 100644 index 000000000..2becb8b30 --- /dev/null +++ b/include/QtNodes/NodeGroup @@ -0,0 +1 @@ +#include "internal/NodeGroup.hpp" diff --git a/include/QtNodes/UndoCommands b/include/QtNodes/UndoCommands new file mode 100644 index 000000000..12f652f4a --- /dev/null +++ b/include/QtNodes/UndoCommands @@ -0,0 +1 @@ +#include "internal/UndoCommands.hpp" diff --git a/include/QtNodes/internal/AbstractGraphModel.hpp b/include/QtNodes/internal/AbstractGraphModel.hpp index 716550114..cf8e2491a 100644 --- a/include/QtNodes/internal/AbstractGraphModel.hpp +++ b/include/QtNodes/internal/AbstractGraphModel.hpp @@ -1,8 +1,8 @@ #pragma once -#include "Export.hpp" #include "ConnectionIdHash.hpp" #include "Definitions.hpp" +#include "Export.hpp" #include #include @@ -10,7 +10,6 @@ #include - namespace QtNodes { /** @@ -51,8 +50,7 @@ class NODE_EDITOR_PUBLIC AbstractGraphModel : public QObject */ virtual std::unordered_set connections(NodeId nodeId, PortType portType, - PortIndex index) const - = 0; + PortIndex index) const = 0; /// Checks if two nodes with the given `connectionId` are connected. virtual bool connectionExists(ConnectionId const connectionId) const = 0; @@ -132,8 +130,10 @@ class NODE_EDITOR_PUBLIC AbstractGraphModel : public QObject * @returns Port Data Type, Port Data, Connection Policy, Port * Caption. */ - virtual QVariant portData(NodeId nodeId, PortType portType, PortIndex index, PortRole role) const - = 0; + virtual QVariant portData(NodeId nodeId, + PortType portType, + PortIndex index, + PortRole role) const = 0; /** * A utility function that unwraps the `QVariant` value returned from the diff --git a/include/QtNodes/internal/AbstractNodeGeometry.hpp b/include/QtNodes/internal/AbstractNodeGeometry.hpp index c3be96301..acc60def5 100644 --- a/include/QtNodes/internal/AbstractNodeGeometry.hpp +++ b/include/QtNodes/internal/AbstractNodeGeometry.hpp @@ -36,7 +36,8 @@ class NODE_EDITOR_PUBLIC AbstractNodeGeometry /// Port position in node's coordinate system. virtual QPointF portPosition(NodeId const nodeId, PortType const portType, - PortIndex const index) const = 0; + PortIndex const index) const + = 0; /// A convenience function using the `portPosition` and a given transformation. virtual QPointF portScenePosition(NodeId const nodeId, @@ -47,7 +48,8 @@ class NODE_EDITOR_PUBLIC AbstractNodeGeometry /// Defines where to draw port label. The point corresponds to a font baseline. virtual QPointF portTextPosition(NodeId const nodeId, PortType const portType, - PortIndex const portIndex) const = 0; + PortIndex const portIndex) const + = 0; /** * Defines where to start drawing the caption. The point corresponds to a font @@ -58,15 +60,6 @@ class NODE_EDITOR_PUBLIC AbstractNodeGeometry /// Caption rect is needed for estimating the total node size. virtual QRectF captionRect(NodeId const nodeId) const = 0; - /** - * Defines where to start drawing the label. The point corresponds to a font - * baseline. - */ - virtual QPointF labelPosition(NodeId const nodeId) const = 0; - - /// Caption rect is needed for estimating the total node size. - virtual QRectF labelRect(NodeId const nodeId) const = 0; - /// Position for an embedded widget. Return any value if you don't embed. virtual QPointF widgetPosition(NodeId const nodeId) const = 0; @@ -76,8 +69,6 @@ class NODE_EDITOR_PUBLIC AbstractNodeGeometry virtual QRect resizeHandleRect(NodeId const nodeId) const = 0; - virtual int getPortSpacing() = 0; - protected: AbstractGraphModel &_graphModel; }; diff --git a/include/QtNodes/internal/BasicGraphicsScene.hpp b/include/QtNodes/internal/BasicGraphicsScene.hpp index 83424c5d8..087ccfb64 100644 --- a/include/QtNodes/internal/BasicGraphicsScene.hpp +++ b/include/QtNodes/internal/BasicGraphicsScene.hpp @@ -5,10 +5,11 @@ #include "ConnectionIdHash.hpp" #include "Definitions.hpp" #include "Export.hpp" +#include "GroupGraphicsObject.hpp" +#include "NodeGroup.hpp" +#include "UndoCommands.hpp" -#include "QUuidStdHash.hpp" - -#include +#include #include #include @@ -17,7 +18,6 @@ #include #include - class QUndoStack; namespace QtNodes { @@ -28,6 +28,11 @@ class AbstractNodePainter; class ConnectionGraphicsObject; class NodeGraphicsObject; class NodeStyle; +class DeleteCommand; +class CopyCommand; +class NodeGroup; +class GroupGraphicsObject; +struct ConnectionId; /// An instance of QGraphicsScene, holds connections and nodes. class NODE_EDITOR_PUBLIC BasicGraphicsScene : public QGraphicsScene @@ -59,8 +64,21 @@ class NODE_EDITOR_PUBLIC BasicGraphicsScene : public QGraphicsScene void setConnectionPainter(std::unique_ptr newPainter); + void setNodeGeometry(std::unique_ptr newGeom); + QUndoStack &undoStack(); + /** + * @brief Setter for the _groupingEnabled flag. + * @param boolean to set or not the flag. + */ + void setGroupingEnabled(bool enabled); + + /** + * @brief Getter for the _groupingEnabled flag. + */ + bool groupingEnabled() const { return _groupingEnabled; } + public: /** * @brief Creates a "draft" instance of ConnectionGraphicsObject. @@ -86,6 +104,83 @@ class NODE_EDITOR_PUBLIC BasicGraphicsScene : public QGraphicsScene /// Deletes all the nodes. Connections are removed automatically. void clearScene(); + /** + * @brief Creates a list of the connections that are incident only to nodes within a + * given group. + * @param groupID ID of the desired group. + * @return List of (pointers of) connections whose both endpoints belong to members of + * the specified group. + */ + std::vector> connectionsWithinGroup(GroupId groupID); + /** + * @brief Creates a group in the scene containing the given nodes. + * @param nodes Reference to the list of nodes to be included in the group. + * @param name Group's name. + * @param groupId Group's id. + * @return Pointer to the newly-created group. + */ + std::weak_ptr createGroup(std::vector &nodes, + QString name = QStringLiteral(""), + GroupId groupId = InvalidGroupId); + + /** + * @brief Creates a group in the scene containing the currently selected nodes. + * @param name Group's name + * @return Pointer to the newly-created group. + */ + std::weak_ptr createGroupFromSelection(QString groupName = QStringLiteral("")); + + /** + * @brief Restores a group from a JSON object. + * @param groupJson JSON object containing the group data. + * @return Pair consisting of a pointer to the newly-created group and the mapping + * between old and new nodes. + */ + std::pair, std::unordered_map> restoreGroup( + QJsonObject const &groupJson); + + /** + * @brief Returns a const reference to the mapping of existing groups. + */ + std::unordered_map> const &groups() const; + + /** + * @brief Loads a group from a file specified by the user. + * @return Pointer to the newly-created group. + */ + std::weak_ptr loadGroupFile(); + + /** + * @brief Saves a group in a .group file. + * @param groupID Group's id. + */ + void saveGroupFile(GroupId groupID); + + /** + * @brief Calculates the selected nodes. + * @return Vector containing the NodeGraphicsObject pointers related to the selected nodes. + */ + std::vector selectedNodes() const; + + /** + * @brief Calculates the selected groups. + * @return Vector containing the GroupGraphicsObject pointers related to the selected groups. + */ + std::vector selectedGroups() const; + + /** + * @brief Adds a node to a group, if both node and group exists. + * @param nodeId Node's id. + * @param groupId Group's id. + */ + void addNodeToGroup(NodeId nodeId, GroupId groupId); + + /** + * @brief Removes a node from a group, if the node exists and is within a group. + * @param nodeId Node's id. + */ + void removeNodeFromGroup(NodeId nodeId); + public: /** * @returns NodeGraphicsObject associated with the given nodeId. @@ -110,6 +205,17 @@ class NODE_EDITOR_PUBLIC BasicGraphicsScene : public QGraphicsScene */ virtual QMenu *createSceneMenu(QPointF const scenePos); + /** + * @brief Creates the default menu when a node is selected. + */ + QMenu *createStdMenu(QPointF const scenePos); + + /** + * @brief Creates the menu when a group is selected. + * @param groupGo reference to the GroupGraphicsObject related to the selected group. + */ + QMenu *createGroupMenu(QPointF const scenePos, GroupGraphicsObject *groupGo); + Q_SIGNALS: void modified(BasicGraphicsScene *); void nodeMoved(NodeId const nodeId, QPointF const &newLocation); @@ -123,6 +229,9 @@ class NODE_EDITOR_PUBLIC BasicGraphicsScene : public QGraphicsScene /// Signal allows showing custom context menu upon clicking a node. void nodeContextMenu(NodeId const nodeId, QPointF const pos); + /// Signals to call Graphics View's zoomFit methods + void zoomFitAllClicked(); + void zoomFitSelectedClicked(); private: /** @@ -137,28 +246,61 @@ class NODE_EDITOR_PUBLIC BasicGraphicsScene : public QGraphicsScene /// Redraws adjacent nodes for given `connectionId` void updateAttachedNodes(ConnectionId const connectionId, PortType const portType); + /** + * @brief Loads a JSON object that represents a node, with the option + * to keep the stored node id or generate a new one. + * @param nodeJson The JSON object representing a node. + * @param keepOriginalId If true, the loaded node will have the same id as the one stored in + * the file; otherwise, a new id will be generated + * @return A reference to the NodeGraphicsObject related to the loaded node. + */ + NodeGraphicsObject &loadNodeToMap(QJsonObject nodeJson, bool keepOriginalId = false); + + /** + * @brief Loads a connection between nodes from a JSON file. + * @param connectionJson JSON object that stores the connection's endpoints. + * @param nodeIdMap Map of nodes (i.e. all possible endpoints). + */ + void loadConnectionToMap(QJsonObject const &connectionJson, + std::unordered_map const &nodeIdMap); + public Q_SLOTS: /// Slot called when the `connectionId` is erased form the AbstractGraphModel. - void onConnectionDeleted(ConnectionId const connectionId); + virtual void onConnectionDeleted(ConnectionId const connectionId); /// Slot called when the `connectionId` is created in the AbstractGraphModel. - void onConnectionCreated(ConnectionId const connectionId); + virtual void onConnectionCreated(ConnectionId const connectionId); - void onNodeDeleted(NodeId const nodeId); - void onNodeCreated(NodeId const nodeId); - void onNodePositionUpdated(NodeId const nodeId); - void onNodeUpdated(NodeId const nodeId); - void onNodeClicked(NodeId const nodeId); - void onModelReset(); + virtual void onNodeDeleted(NodeId const nodeId); + virtual void onNodeCreated(NodeId const nodeId); + virtual void onNodePositionUpdated(NodeId const nodeId); + virtual void onNodeUpdated(NodeId const nodeId); + virtual void onNodeClicked(NodeId const nodeId); + virtual void onModelReset(); + + /** + * @brief Slot called to trigger the copy command action. + */ + void onCopySelectedObjects() { undoStack().push(new CopyCommand(this)); } + + /** + * @brief Slot called to trigger the delete command action. + */ + void onDeleteSelectedObjects() { undoStack().push(new DeleteCommand(this)); } private: AbstractGraphModel &_graphModel; using UniqueNodeGraphicsObject = std::unique_ptr; using UniqueConnectionGraphicsObject = std::unique_ptr; + using SharedGroup = std::shared_ptr; std::unordered_map _nodeGraphicsObjects; std::unordered_map _connectionGraphicsObjects; + GroupId nextGroupId(); + + std::unordered_map _groups{}; + GroupId _nextGroupId{0}; std::unique_ptr _draftConnection; std::unique_ptr _nodeGeometry; std::unique_ptr _nodePainter; @@ -166,6 +308,7 @@ public Q_SLOTS: bool _nodeDrag; QUndoStack *_undoStack; Qt::Orientation _orientation; + bool _groupingEnabled; }; } // namespace QtNodes diff --git a/include/QtNodes/internal/ConnectionIdUtils.hpp b/include/QtNodes/internal/ConnectionIdUtils.hpp index 7f70a1b4b..3e29babf2 100644 --- a/include/QtNodes/internal/ConnectionIdUtils.hpp +++ b/include/QtNodes/internal/ConnectionIdUtils.hpp @@ -132,7 +132,7 @@ inline QJsonObject toJson(ConnectionId const &connId) connJson["outNodeId"] = static_cast(connId.outNodeId); connJson["outPortIndex"] = static_cast(connId.outPortIndex); - connJson["intNodeId"] = static_cast(connId.inNodeId); + connJson["inNodeId"] = static_cast(connId.inNodeId); connJson["inPortIndex"] = static_cast(connId.inPortIndex); return connJson; @@ -140,9 +140,17 @@ inline QJsonObject toJson(ConnectionId const &connId) inline ConnectionId fromJson(QJsonObject const &connJson) { + // Support both "inNodeId" (correct) and "intNodeId" (legacy typo) for backward compatibility + NodeId inNodeId = InvalidNodeId; + if (connJson.contains("inNodeId")) { + inNodeId = static_cast(connJson["inNodeId"].toInt(InvalidNodeId)); + } else if (connJson.contains("intNodeId")) { + inNodeId = static_cast(connJson["intNodeId"].toInt(InvalidNodeId)); + } + ConnectionId connId{static_cast(connJson["outNodeId"].toInt(InvalidNodeId)), static_cast(connJson["outPortIndex"].toInt(InvalidPortIndex)), - static_cast(connJson["intNodeId"].toInt(InvalidNodeId)), + inNodeId, static_cast(connJson["inPortIndex"].toInt(InvalidPortIndex))}; return connId; diff --git a/include/QtNodes/internal/DataFlowGraphModel.hpp b/include/QtNodes/internal/DataFlowGraphModel.hpp index fcde1482c..b1ef892e8 100644 --- a/include/QtNodes/internal/DataFlowGraphModel.hpp +++ b/include/QtNodes/internal/DataFlowGraphModel.hpp @@ -11,12 +11,12 @@ #include #include -#include -#include namespace QtNodes { -class NODE_EDITOR_PUBLIC DataFlowGraphModel : public AbstractGraphModel, public Serializable +class NODE_EDITOR_PUBLIC DataFlowGraphModel + : public AbstractGraphModel + , public Serializable { Q_OBJECT @@ -45,7 +45,6 @@ class NODE_EDITOR_PUBLIC DataFlowGraphModel : public AbstractGraphModel, public NodeId addNode(QString const nodeType) override; - bool connectionPossible(ConnectionId const connectionId) const override; void addConnection(ConnectionId const connectionId) override; @@ -77,7 +76,6 @@ class NODE_EDITOR_PUBLIC DataFlowGraphModel : public AbstractGraphModel, public void loadNode(QJsonObject const &nodeJson) override; - // From Serializable QJsonObject save() const override; @@ -139,9 +137,6 @@ private Q_SLOTS: std::unordered_set _connectivity; mutable std::unordered_map _nodeGeometryData; - - std::unordered_map _labels; - std::unordered_map _labelsVisible; }; } // namespace QtNodes diff --git a/include/QtNodes/internal/DefaultHorizontalNodeGeometry.hpp b/include/QtNodes/internal/DefaultHorizontalNodeGeometry.hpp index b26555282..33367e109 100644 --- a/include/QtNodes/internal/DefaultHorizontalNodeGeometry.hpp +++ b/include/QtNodes/internal/DefaultHorizontalNodeGeometry.hpp @@ -28,21 +28,14 @@ class NODE_EDITOR_PUBLIC DefaultHorizontalNodeGeometry : public AbstractNodeGeom QPointF portTextPosition(NodeId const nodeId, PortType const portType, PortIndex const PortIndex) const override; - QPointF captionPosition(NodeId const nodeId) const override; QRectF captionRect(NodeId const nodeId) const override; - QPointF labelPosition(const NodeId nodeId) const override; - - QRectF labelRect(NodeId const nodeId) const override; - QPointF widgetPosition(NodeId const nodeId) const override; QRect resizeHandleRect(NodeId const nodeId) const override; - int getPortSpacing() override { return _portSpacing; } - private: QRectF portTextRect(NodeId const nodeId, PortType const portType, @@ -59,7 +52,7 @@ class NODE_EDITOR_PUBLIC DefaultHorizontalNodeGeometry : public AbstractNodeGeom // constness of the Node. mutable unsigned int _portSize; - unsigned int _portSpacing; + unsigned int _portSpasing; mutable QFontMetrics _fontMetrics; mutable QFontMetrics _boldFontMetrics; }; diff --git a/include/QtNodes/internal/DefaultNodePainter.hpp b/include/QtNodes/internal/DefaultNodePainter.hpp index 7a4f21a2c..dbea2a3c0 100644 --- a/include/QtNodes/internal/DefaultNodePainter.hpp +++ b/include/QtNodes/internal/DefaultNodePainter.hpp @@ -28,8 +28,6 @@ class NODE_EDITOR_PUBLIC DefaultNodePainter : public AbstractNodePainter void drawNodeCaption(QPainter *painter, NodeGraphicsObject &ngo) const; - void drawNodeLabel(QPainter *painter, NodeGraphicsObject &ngo) const; - void drawEntryLabels(QPainter *painter, NodeGraphicsObject &ngo) const; void drawResizeRect(QPainter *painter, NodeGraphicsObject &ngo) const; @@ -39,6 +37,6 @@ class NODE_EDITOR_PUBLIC DefaultNodePainter : public AbstractNodePainter void drawValidationIcon(QPainter *painter, NodeGraphicsObject &ngo) const; private: - QIcon _toolTipIcon{"://info-tooltip.svg"}; + QIcon _toolTipIcon{":/info-tooltip.svg"}; }; } // namespace QtNodes diff --git a/include/QtNodes/internal/DefaultVerticalNodeGeometry.hpp b/include/QtNodes/internal/DefaultVerticalNodeGeometry.hpp index d20636567..ce4dd9f17 100644 --- a/include/QtNodes/internal/DefaultVerticalNodeGeometry.hpp +++ b/include/QtNodes/internal/DefaultVerticalNodeGeometry.hpp @@ -33,16 +33,10 @@ class NODE_EDITOR_PUBLIC DefaultVerticalNodeGeometry : public AbstractNodeGeomet QRectF captionRect(NodeId const nodeId) const override; - QPointF labelPosition(const NodeId nodeId) const override; - - QRectF labelRect(NodeId const nodeId) const override; - QPointF widgetPosition(NodeId const nodeId) const override; QRect resizeHandleRect(NodeId const nodeId) const override; - int getPortSpacing() override { return _portSpacing; } - private: QRectF portTextRect(NodeId const nodeId, PortType const portType, @@ -60,7 +54,7 @@ class NODE_EDITOR_PUBLIC DefaultVerticalNodeGeometry : public AbstractNodeGeomet // constness of the Node. mutable unsigned int _portSize; - unsigned int _portSpacing; + unsigned int _portSpasing; mutable QFontMetrics _fontMetrics; mutable QFontMetrics _boldFontMetrics; }; diff --git a/include/QtNodes/internal/Definitions.hpp b/include/QtNodes/internal/Definitions.hpp index 19a3b16e3..fc4ffc26d 100644 --- a/include/QtNodes/internal/Definitions.hpp +++ b/include/QtNodes/internal/Definitions.hpp @@ -22,23 +22,19 @@ Q_NAMESPACE_EXPORT(NODE_EDITOR_PUBLIC) * Constants used for fetching QVariant data from GraphModel. */ enum class NodeRole { - Type = 0, ///< Type of the current node, usually a string. - Position = 1, ///< `QPointF` positon of the node on the scene. - Size = 2, ///< `QSize` for resizable nodes. - CaptionVisible = 3, ///< `bool` for caption visibility. - Caption = 4, ///< `QString` for node caption. - Style = 5, ///< Custom NodeStyle as QJsonDocument - InternalData = 6, ///< Node-stecific user data as QJsonObject - InPortCount = 7, ///< `unsigned int` - OutPortCount = 9, ///< `unsigned int` - Widget = 10, ///< Optional `QWidget*` or `nullptr` - ValidationState = 11, ///< Enum NodeValidationState of the node - LabelVisible = 12, ///< `bool` for label visibility. - ProcessingStatus = 13, ///< Enum NodeProcessingStatus of the node - Label = 14, ///< `QString` for node label. - LabelEditable = 15, ///< `bool` to indicate label editing support. + Type = 0, ///< Type of the current node, usually a string. + Position = 1, ///< `QPointF` positon of the node on the scene. + Size = 2, ///< `QSize` for resizable nodes. + CaptionVisible = 3, ///< `bool` for caption visibility. + Caption = 4, ///< `QString` for node caption. + Style = 5, ///< Custom NodeStyle as QJsonDocument + InternalData = 6, ///< Node-stecific user data as QJsonObject + InPortCount = 7, ///< `unsigned int` + OutPortCount = 9, ///< `unsigned int` + Widget = 10, ///< Optional `QWidget*` or `nullptr` + ValidationState = 11, ///< Enum NodeValidationState of the node + ProcessingStatus = 12 ///< Enum NodeProcessingStatus of the node }; - Q_ENUM_NS(NodeRole) /** @@ -98,6 +94,11 @@ using NodeId = unsigned int; static constexpr NodeId InvalidNodeId = std::numeric_limits::max(); +/// Unique Id associated with each node group. +using GroupId = unsigned int; + +static constexpr GroupId InvalidGroupId = std::numeric_limits::max(); + /** * A unique connection identificator that stores * out `NodeId`, out `PortIndex`, in `NodeId`, in `PortIndex` diff --git a/include/QtNodes/internal/GraphicsView.hpp b/include/QtNodes/internal/GraphicsView.hpp index 1fbe5a890..fdc5af7ce 100644 --- a/include/QtNodes/internal/GraphicsView.hpp +++ b/include/QtNodes/internal/GraphicsView.hpp @@ -2,11 +2,8 @@ #include -#include "Definitions.hpp" #include "Export.hpp" -class QLineEdit; - namespace QtNodes { class BasicGraphicsScene; @@ -53,13 +50,17 @@ public Q_SLOTS: void setupScale(double scale); - void onDeleteSelectedObjects(); + virtual void onDeleteSelectedObjects(); + + virtual void onDuplicateSelectedObjects(); - void onDuplicateSelectedObjects(); + virtual void onCopySelectedObjects(); - void onCopySelectedObjects(); + virtual void onPasteObjects(); - void onPasteObjects(); + void zoomFitAll(); + + void zoomFitSelected(); Q_SIGNALS: void scaleChanged(double scale); @@ -90,14 +91,12 @@ public Q_SLOTS: private: QAction *_clearSelectionAction = nullptr; QAction *_deleteSelectionAction = nullptr; + QAction *_cutSelectionAction = nullptr; QAction *_duplicateSelectionAction = nullptr; QAction *_copySelectionAction = nullptr; QAction *_pasteAction = nullptr; QPointF _clickPos; ScaleRange _scaleRange; - - QLineEdit *_labelEdit = nullptr; - NodeId _editingNodeId = InvalidNodeId; }; } // namespace QtNodes diff --git a/include/QtNodes/internal/GroupGraphicsObject.hpp b/include/QtNodes/internal/GroupGraphicsObject.hpp new file mode 100644 index 000000000..1b635fbd4 --- /dev/null +++ b/include/QtNodes/internal/GroupGraphicsObject.hpp @@ -0,0 +1,256 @@ +#pragma once + +#include "BasicGraphicsScene.hpp" +#include "Definitions.hpp" +#include "NodeGroup.hpp" +#include +#include + +/** + * @brief The IconGraphicsItem class is an auxiliary class that implements + * custom behaviour to a fixed-size icon object. + */ +class IconGraphicsItem : public QGraphicsPixmapItem +{ +public: + IconGraphicsItem(QGraphicsItem *parent = nullptr); + + IconGraphicsItem(const QPixmap &pixmap, QGraphicsItem *parent = nullptr); + + /** + * @brief Returns the factor by which the original image was scaled + * to fit the desired icon size. + */ + double scaleFactor() const; + + /** + * @brief Returns the icon size. + */ + static constexpr double iconSize() { return _iconSize; } + +private: + double _scaleFactor{}; + +private: + static constexpr double _iconSize = 24.0; +}; + +namespace QtNodes { + +class BasicGraphicsScene; +class NodeGroup; +class NodeGraphicsObject; + +/** + * @brief The GroupGraphicsObject class handles the graphical part of a node group. + * Each node group is associated with a unique GroupGraphicsObject. + */ +class GroupGraphicsObject + : public QObject + , public QGraphicsRectItem +{ + Q_OBJECT + +public: + /** + * @brief Constructor that creates a group's graphical object that should be + * included in the given scene and associated with the given NodeGroup object. + * @param scene Reference to the scene that will include this object. + * @param nodeGroup Reference to the group associated with this object. + */ + GroupGraphicsObject(BasicGraphicsScene &scene, NodeGroup &nodeGroup); + + GroupGraphicsObject(const GroupGraphicsObject &ggo) = delete; + GroupGraphicsObject &operator=(const GroupGraphicsObject &other) = delete; + GroupGraphicsObject(GroupGraphicsObject &&ggo) = delete; + GroupGraphicsObject &operator=(GroupGraphicsObject &&other) = delete; + + ~GroupGraphicsObject() override; + + /** + * @brief Returns a reference to this object's associated group. + */ + NodeGroup &group(); + + /** + * @brief Returns a const reference to this object's associated group. + */ + NodeGroup const &group() const; + + /** + * @copydoc QGraphicsItem::boundingRect() + */ + QRectF boundingRect() const override; + + enum { Type = UserType + 3 }; + + /** + * @copydoc QGraphicsItem::type() + */ + int type() const override { return Type; } + + /** + * @brief Sets the group's area color. + * @param color Color to paint the group area. + */ + void setFillColor(const QColor &color); + + /** + * @brief Sets the group's border color. + * @param color Color to paint the group's border. + */ + void setBorderColor(const QColor &color); + + /** + * @brief Updates the position of all the connections that are incident + * to the nodes of this group. + */ + void moveConnections(); + + /** + * @brief Moves the position of all the nodes of this group by the amount given. + * @param offset 2D vector representing the amount by which the group has moved. + */ + void moveNodes(const QPointF &offset); + + /** + * @brief Sets the lock state of the group. Locked groups don't allow individual + * interactions with its nodes, and can only be moved or selected as a whole. + * @param locked Determines whether this group should be locked. + */ + void lock(bool locked); + + /** + * @brief Returns the lock state of the group. Locked groups don't allow individual + * interactions with its nodes, and can only be moved or selected as a whole. + */ + bool locked() const; + + /** + * @brief Updates the position of the group's padlock icon to + * the top-right corner. + */ + void positionLockedIcon(); + + /** + * @brief Sets the group hovered state. When the mouse pointer hovers over + * (or leaves) a group, the group's appearance changes. + * @param hovered Determines the hovered state. + */ + void setHovered(bool hovered); + + /** + * @brief When a node is dragged within the borders of a group, the group's + * area expands to include the node until the node leaves the area or is + * released in the group. This function temporarily sets the node as the + * possible newest member of the group, making the group's area expand. + * @param possibleChild Pointer to the node that may be included. + */ + void setPossibleChild(NodeGraphicsObject *possibleChild); + + /** + * @brief Clears the possibleChild variable. + * @note See setPossibleChild(NodeGraphicsObject*). + */ + void unsetPossibleChild(); + + /** + * @brief Returns all the connections that are incident strictly within the + * nodes of this group. + */ + std::vector> connections() const; + + /** + * @brief Sets the position of the group. + * @param position The desired (top-left corner) position of the group, in + * scene coordinates. + */ + void setPosition(const QPointF &position); + +protected: + /** @copydoc QGraphicsItem::paint() */ + void paint(QPainter *painter, + QStyleOptionGraphicsItem const *option, + QWidget *widget = nullptr) override; + + /** @copydoc QGraphicsItem::hoverEnterEvent() */ + void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override; + + /** @copydoc QGraphicsItem::hoverLeaveEvent() */ + void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override; + + /** @copydoc QGraphicsItem::mouseMoveEvent() */ + void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; + + /** @copydoc QGraphicsItem::mouseDoubleClickEvent() */ + void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override; + +public: + /** + * @brief _currentFillColor Holds the current color of the group background. + */ + QColor _currentFillColor; + + /** + * @brief _currentBorderColor Holds the current color of the group border. + */ + QColor _currentBorderColor; + + const QColor kUnlockedFillColor = QColor("#20a5b084"); + const QColor kUnlockedHoverColor = QColor("#2083a4af"); + + const QColor kLockedFillColor = QColor("#3fe0bebc"); + const QColor kLockedHoverColor = QColor("#3feecdcb"); + + const QColor kSelectedBorderColor = QColor("#eeffa500"); + const QColor kUnselectedBorderColor = QColor("#eeaaaaaa"); + + /** + * @brief _borderPen Object that dictates how the group border should be drawn. + */ + QPen _borderPen; + +private: + /** + * @brief _scene Reference to the scene object in which this object is included. + */ + BasicGraphicsScene &_scene; + + /** + * @brief _group Reference to the group instance that corresponds to this object. + */ + NodeGroup &_group; + + IconGraphicsItem *_lockedGraphicsItem; + IconGraphicsItem *_unlockedGraphicsItem; + + QPixmap _lockedIcon{QStringLiteral("://padlock-lock.png")}; + QPixmap _unlockedIcon{QStringLiteral("://padlock-unlock.png")}; + + /** + * @brief _possibleChild Pointer that temporarily is set to an existing node when + * the user drags the node to this group's area. + */ + NodeGraphicsObject *_possibleChild; + + /** + * @brief _locked Holds the lock state of the group. Locked groups don't allow individual + * interactions with its nodes, and can only be moved or selected as a whole. + */ + bool _locked; + + static constexpr double _groupBorderX = 25.0; + static constexpr double _groupBorderY = _groupBorderX * 0.8; + static constexpr double _roundedBorderRadius = _groupBorderY; + static constexpr QMarginsF _margins = QMarginsF(_groupBorderX, + _groupBorderY + IconGraphicsItem::iconSize(), + _groupBorderX + IconGraphicsItem::iconSize(), + _groupBorderY); + + static constexpr double _defaultWidth = 50.0; + static constexpr double _defaultHeight = 50.0; + + static constexpr double _groupAreaZValue = 2.0; +}; + +} // namespace QtNodes diff --git a/include/QtNodes/internal/NodeConnectionInteraction.hpp b/include/QtNodes/internal/NodeConnectionInteraction.hpp index b22f3c7a4..f71ae97e0 100644 --- a/include/QtNodes/internal/NodeConnectionInteraction.hpp +++ b/include/QtNodes/internal/NodeConnectionInteraction.hpp @@ -1,7 +1,7 @@ #pragma once #include "Definitions.hpp" - +#include "NodeGraphicsObject.hpp" #include namespace QtNodes { @@ -53,6 +53,11 @@ class NodeConnectionInteraction */ bool disconnect(PortType portToDisconnect) const; + /** + * @brief Getter for the NodeGraphicsObject object. + */ + NodeGraphicsObject &nodeGraphicsObject() { return _ngo; } + private: PortType connectionRequiredPort() const; diff --git a/include/QtNodes/internal/NodeDelegateModel.hpp b/include/QtNodes/internal/NodeDelegateModel.hpp index 5215c3692..3d3778f87 100644 --- a/include/QtNodes/internal/NodeDelegateModel.hpp +++ b/include/QtNodes/internal/NodeDelegateModel.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include "Definitions.hpp" @@ -11,7 +12,6 @@ #include "NodeData.hpp" #include "NodeStyle.hpp" #include "Serializable.hpp" -#include namespace QtNodes { @@ -65,15 +65,15 @@ class NODE_EDITOR_PUBLIC NodeDelegateModel virtual ~NodeDelegateModel() = default; - /// It is possible to hide caption in GUI - virtual bool captionVisible() const { return true; } - /// Name makes this model unique virtual QString name() const = 0; /// Caption is used in GUI virtual QString caption() const = 0; + /// It is possible to hide caption in GUI + virtual bool captionVisible() const { return true; } + /// Port caption is used in GUI to label individual ports virtual QString portCaption(PortType, PortIndex) const { return QString(); } @@ -83,15 +83,6 @@ class NODE_EDITOR_PUBLIC NodeDelegateModel /// Validation State will default to Valid, but you can manipulate it by overriding in an inherited class virtual NodeValidationState validationState() const { return _nodeValidationState; } - /// Nicknames can be assigned to nodes and shown in GUI - virtual QString label() const { return QString(); } - - /// It is possible to hide the nickname in GUI - virtual bool labelVisible() const { return true; } - - /// Controls whether the label can be edited or not - virtual bool labelEditable() const { return false; } - /// Returns the curent processing status virtual NodeProcessingStatus processingStatus() const { return _processingStatus; } @@ -99,10 +90,11 @@ class NODE_EDITOR_PUBLIC NodeDelegateModel void load(QJsonObject const &) override; - void setValidationState(const NodeValidationState &validationState); - void setNodeProcessingStatus(NodeProcessingStatus status); + void setValidationState(const NodeValidationState &validationState); + +public: virtual unsigned int nPorts(PortType portType) const = 0; virtual NodeDataType dataType(PortType portType, PortIndex portIndex) const = 0; @@ -155,7 +147,6 @@ public Q_SLOTS: void dataInvalidated(PortIndex const index); void computingStarted(); - void computingFinished(); void embeddedWidgetSizeUpdated(); @@ -169,6 +160,7 @@ public Q_SLOTS: void requestNodeUpdate(); /// Call this function before deleting the data associated with ports. + /** * @brief Call this function before deleting the data associated with ports. * The function notifies the Graph Model and makes it remove and recompute the diff --git a/include/QtNodes/internal/NodeGraphicsObject.hpp b/include/QtNodes/internal/NodeGraphicsObject.hpp index b3c01b904..e3226802e 100644 --- a/include/QtNodes/internal/NodeGraphicsObject.hpp +++ b/include/QtNodes/internal/NodeGraphicsObject.hpp @@ -1,9 +1,14 @@ #pragma once +#include "NodeDelegateModel.hpp" +#include "NodeGroup.hpp" +#include "NodeState.hpp" #include +#include #include #include +#include "Export.hpp" #include "NodeState.hpp" class QGraphicsProxyWidget; @@ -12,8 +17,11 @@ namespace QtNodes { class BasicGraphicsScene; class AbstractGraphModel; +class NodeGroup; +class NodeDelegateModel; +class GroupGraphicsObject; -class NodeGraphicsObject : public QGraphicsObject +class NODE_EDITOR_PUBLIC NodeGraphicsObject : public QGraphicsObject { Q_OBJECT public: @@ -51,8 +59,25 @@ class NodeGraphicsObject : public QGraphicsObject /// Repaints the node once with reacting ports. void reactToConnection(ConnectionGraphicsObject const *cgo); + /// Lockes/unlockes nodes in a selected node group. + void lock(bool locked); + void updateQWidgetEmbedPos(); + /// Saves node in a QJsonObject save file. + QJsonObject save() const; + + /** @brief Setter for the NodeGroup object. + * @param shared pointer to the node group. + */ + void setNodeGroup(std::shared_ptr group); + + /// Unsets NodeGroup, setting it to an empty pointer. + void unsetNodeGroup() { _nodeGroup = std::weak_ptr(); } + + /// Getter for the NodeGroup object. + std::weak_ptr nodeGroup() const { return _nodeGroup; } + protected: void paint(QPainter *painter, QStyleOptionGraphicsItem const *option, @@ -80,7 +105,15 @@ class NodeGraphicsObject : public QGraphicsObject NodeState _nodeState; + bool _locked; + + bool _draggingIntoGroup; + GroupGraphicsObject *_possibleGroup; + QRectF _originalGroupSize; + // either nullptr or owned by parent QGraphicsItem QGraphicsProxyWidget *_proxyWidget; + + std::weak_ptr _nodeGroup{}; }; } // namespace QtNodes diff --git a/include/QtNodes/internal/NodeGroup.hpp b/include/QtNodes/internal/NodeGroup.hpp new file mode 100644 index 000000000..f8accc0a8 --- /dev/null +++ b/include/QtNodes/internal/NodeGroup.hpp @@ -0,0 +1,144 @@ +#pragma once + +#include +#include +#include + +#include "DataFlowGraphModel.hpp" +#include "Definitions.hpp" +#include "Export.hpp" +#include "GroupGraphicsObject.hpp" +#include "NodeConnectionInteraction.hpp" +#include "NodeState.hpp" + +namespace QtNodes { + +class DataFlowGraphModel; +class GroupGraphicsObject; +class NodeState; +class NodeConnectionInteraction; +class NodeGraphicsObject; +struct ConnectionId; + +/** + * @brief The NodeGroup class defines a controller for node groups. It is + * responsible for managing the group's data and the interactions with other + * classes. + */ +class NODE_EDITOR_PUBLIC NodeGroup : public QObject +{ + Q_OBJECT + +public: + /** + * @brief Constructor to create a node group with the given nodes. + * @param nodes List of node pointers to be included in the group. + * @param groupId Group's identifier. + * @param name Group's name. If none is given, it is automatically generated. + * @param parent Parent object. + */ + NodeGroup(std::vector nodes, + GroupId groupId, + QString name = QString(), + QObject *parent = nullptr); + +public: + /** + * @brief Prepares a byte array containing this group's data to be saved in a + * file. + * @return A byte array containing this group's data (in JSON format). + */ + QByteArray saveToFile() const; + + /** + * @brief Returns this group's identifier. + */ + GroupId id() const; + + /** + * @brief Returns a reference to this group's graphical object. + */ + GroupGraphicsObject &groupGraphicsObject(); + + /** + * @brief Returns a const reference to this group's graphical object. + */ + GroupGraphicsObject const &groupGraphicsObject() const; + + /** + * @brief Returns the list of nodes that belong to this group. + */ + std::vector &childNodes(); + + /** + * @brief Returns a list of IDs of the nodes that belong to this group. + */ + std::vector nodeIDs() const; + + /** + * @brief Returns this group's name. + */ + QString const &name() const; + + /** + * @brief Associates a GroupGraphicsObject with this group. + */ + void setGraphicsObject(std::unique_ptr &&graphics_object); + + /** + * @brief Returns whether the group's list of nodes is empty. + */ + bool empty() const; + + /** + * @brief Returns the number of groups created during the program's execution. + * Used when automatically naming groups. + */ + static int groupCount(); + +public Q_SLOTS: + /** + * @brief Adds a node to this group. + * @param node Pointer to the node to be added. + */ + void addNode(QtNodes::NodeGraphicsObject *node); + + /** + * @brief Removes a node from this group. + * @param node Pointer to the node to be removed. + */ + void removeNode(QtNodes::NodeGraphicsObject *node); + +private: + /** + * @brief Group's name, just a label so the user can easily identify and + * label groups. It is not a unique identifier for the group. + */ + QString _name; + + // addressing + /** + * @brief Identifier of this group. It is the only unique identifier of + * the group. + */ + GroupId _id; + + // data + /** + * @brief List of pointers of nodes that belong to this group. + */ + std::vector _childNodes; + + // painting + /** + * @brief Pointer to the graphical object associated with this group. + */ + std::unique_ptr _groupGraphicsObject; + + /** + * @brief Static variable to count the number of instances of groups that + * were created during execution. Used when automatically naming groups. + */ + static int _groupCount; +}; +} // namespace QtNodes diff --git a/include/QtNodes/internal/NodeState.hpp b/include/QtNodes/internal/NodeState.hpp index 18940214c..82394430e 100644 --- a/include/QtNodes/internal/NodeState.hpp +++ b/include/QtNodes/internal/NodeState.hpp @@ -7,9 +7,8 @@ #include #include -#include "Export.hpp" - #include "Definitions.hpp" +#include "Export.hpp" #include "NodeData.hpp" namespace QtNodes { diff --git a/include/QtNodes/internal/NodeStyle.hpp b/include/QtNodes/internal/NodeStyle.hpp index f3fb04bbc..01ecd2ea1 100644 --- a/include/QtNodes/internal/NodeStyle.hpp +++ b/include/QtNodes/internal/NodeStyle.hpp @@ -78,12 +78,13 @@ class NODE_EDITOR_PUBLIC NodeStyle : public Style float Opacity; - QIcon statusUpdated{QStringLiteral("://status_icons/updated.svg")}; - QIcon statusProcessing{QStringLiteral("://status_icons/processing.svg")}; - QIcon statusPending{QStringLiteral("://status_icons/pending.svg")}; - QIcon statusInvalid{QStringLiteral("://status_icons/failed.svg")}; - QIcon statusEmpty{QStringLiteral("://status_icons/empty.svg")}; - QIcon statusPartial{QStringLiteral("://status_icons/partial.svg")}; + // Status icons - initialized in constructor after Q_INIT_RESOURCE + QIcon statusUpdated; + QIcon statusProcessing; + QIcon statusPending; + QIcon statusInvalid; + QIcon statusEmpty; + QIcon statusPartial; ProcessingIconStyle processingIconStyle{}; }; diff --git a/include/QtNodes/internal/UndoCommands.hpp b/include/QtNodes/internal/UndoCommands.hpp index 870478618..7aed4d60b 100644 --- a/include/QtNodes/internal/UndoCommands.hpp +++ b/include/QtNodes/internal/UndoCommands.hpp @@ -3,9 +3,9 @@ #include "Definitions.hpp" #include "Export.hpp" -#include #include #include +#include #include diff --git a/resources/padlock-lock.png b/resources/padlock-lock.png new file mode 100644 index 000000000..da36c257e Binary files /dev/null and b/resources/padlock-lock.png differ diff --git a/resources/padlock-unlock.png b/resources/padlock-unlock.png new file mode 100644 index 000000000..0fd623592 Binary files /dev/null and b/resources/padlock-unlock.png differ diff --git a/resources/resources.qrc b/resources/resources.qrc index f9da26fcd..370c42066 100644 --- a/resources/resources.qrc +++ b/resources/resources.qrc @@ -7,6 +7,8 @@ status_icons/partial.svg status_icons/pending.svg status_icons/processing.svg - status_icons/updated.svg + status_icons/updated.svg + padlock-lock.png + padlock-unlock.png diff --git a/src/AbstractGraphModel.cpp b/src/AbstractGraphModel.cpp index 10709b7e8..3ea47435d 100644 --- a/src/AbstractGraphModel.cpp +++ b/src/AbstractGraphModel.cpp @@ -40,7 +40,9 @@ void AbstractGraphModel::portsAboutToBeDeleted(NodeId const nodeId, // Erases the information about the port on one side; auto c = makeIncompleteConnectionId(connectionId, portType); - c = makeCompleteConnectionId(c, nodeId, portIndex - nRemovedPorts); + c = makeCompleteConnectionId(c, + nodeId, + portIndex - static_cast(nRemovedPorts)); _shiftedByDynamicPortsConnections.push_back(c); @@ -84,7 +86,9 @@ void AbstractGraphModel::portsAboutToBeInserted(NodeId const nodeId, // Erases the information about the port on one side; auto c = makeIncompleteConnectionId(connectionId, portType); - c = makeCompleteConnectionId(c, nodeId, portIndex + nNewPorts); + c = makeCompleteConnectionId(c, + nodeId, + portIndex + static_cast(nNewPorts)); _shiftedByDynamicPortsConnections.push_back(c); diff --git a/src/BasicGraphicsScene.cpp b/src/BasicGraphicsScene.cpp index 7cec5ec45..f33e4c846 100644 --- a/src/BasicGraphicsScene.cpp +++ b/src/BasicGraphicsScene.cpp @@ -7,10 +7,15 @@ #include "DefaultHorizontalNodeGeometry.hpp" #include "DefaultNodePainter.hpp" #include "DefaultVerticalNodeGeometry.hpp" +#include "GraphicsView.hpp" #include "NodeGraphicsObject.hpp" #include +#include +#include +#include +#include #include #include @@ -18,9 +23,12 @@ #include #include #include +#include #include #include #include +#include +#include #include #include @@ -29,6 +37,45 @@ #include #include +namespace { + +using QtNodes::GroupId; +using QtNodes::InvalidGroupId; +using QtNodes::InvalidNodeId; +using QtNodes::NodeId; + +NodeId jsonValueToNodeId(QJsonValue const &value) +{ + if (value.isDouble()) { + return static_cast(value.toInt()); + } + + if (value.isString()) { + auto const textValue = value.toString(); + + bool ok = false; + auto const numericValue = textValue.toULongLong(&ok, 10); + if (ok) { + return static_cast(numericValue); + } + + QUuid uuidValue(textValue); + if (!uuidValue.isNull()) { + auto const bytes = uuidValue.toRfc4122(); + if (bytes.size() >= static_cast(sizeof(quint32))) { + QDataStream stream(bytes); + quint32 value32 = 0U; + stream >> value32; + return static_cast(value32); + } + } + } + + return InvalidNodeId; +} + +} // namespace + namespace QtNodes { BasicGraphicsScene::BasicGraphicsScene(AbstractGraphModel &graphModel, QObject *parent) @@ -40,6 +87,7 @@ BasicGraphicsScene::BasicGraphicsScene(AbstractGraphModel &graphModel, QObject * , _nodeDrag(false) , _undoStack(new QUndoStack(this)) , _orientation(Qt::Horizontal) + , _groupingEnabled(true) { setItemIndexMethod(QGraphicsScene::NoIndex); @@ -122,11 +170,43 @@ void BasicGraphicsScene::setConnectionPainter(std::unique_ptr newGeom) +{ + _nodeGeometry = std::move(newGeom); +} + QUndoStack &BasicGraphicsScene::undoStack() { return *_undoStack; } +void BasicGraphicsScene::setGroupingEnabled(bool enabled) +{ + if (_groupingEnabled == enabled) + return; + + if (!enabled) { + for (auto &groupEntry : _groups) { + auto &group = groupEntry.second; + if (!group) + continue; + + for (auto *node : group->childNodes()) { + if (!node) + continue; + + node->unsetNodeGroup(); + node->lock(false); + } + } + + _groups.clear(); + _nextGroupId = 0; + } + + _groupingEnabled = enabled; +} + std::unique_ptr const &BasicGraphicsScene::makeDraftConnection( ConnectionId const incompleteConnectionId) { @@ -151,6 +231,28 @@ void BasicGraphicsScene::clearScene() } } +std::vector> BasicGraphicsScene::connectionsWithinGroup(GroupId groupID) +{ + if (!_groupingEnabled) + return {}; + + std::vector> ret{}; + + for (auto const &connection : _connectionGraphicsObjects) { + auto outNode = nodeGraphicsObject(connection.first.outNodeId); + auto inNode = nodeGraphicsObject(connection.first.inNodeId); + if (outNode && inNode) { + auto group1 = outNode->nodeGroup().lock(); + auto group2 = inNode->nodeGroup().lock(); + if (group1 && group2 && group1->id() == group2->id() && group1->id() == groupID) { + ret.push_back(std::make_shared(connection.first)); + } + } + } + + return ret; +} + NodeGraphicsObject *BasicGraphicsScene::nodeGraphicsObject(NodeId nodeId) { NodeGraphicsObject *ngo = nullptr; @@ -265,6 +367,7 @@ void BasicGraphicsScene::onNodeDeleted(NodeId const nodeId) { auto it = _nodeGraphicsObjects.find(nodeId); if (it != _nodeGraphicsObjects.end()) { + removeNodeFromGroup(nodeId); _nodeGraphicsObjects.erase(it); Q_EMIT modified(this); @@ -322,4 +425,383 @@ void BasicGraphicsScene::onModelReset() traverseGraphAndPopulateGraphicsObjects(); } +std::weak_ptr BasicGraphicsScene::createGroup(std::vector &nodes, + QString groupName, + GroupId groupId) +{ + if (!_groupingEnabled) + return std::weak_ptr(); + + if (nodes.empty()) + return std::weak_ptr(); + + for (auto *node : nodes) { + if (!node->nodeGroup().expired()) + removeNodeFromGroup(node->nodeId()); + } + + if (groupName.isEmpty()) { + groupName = "Group " + QString::number(NodeGroup::groupCount()); + } + + if (groupId == InvalidGroupId) { + groupId = nextGroupId(); + } else { + if (_groups.count(groupId) != 0) { + throw std::runtime_error("Group identifier collision"); + } + + if (groupId >= _nextGroupId && _nextGroupId != InvalidGroupId) { + _nextGroupId = groupId + 1; + } + } + + auto group = std::make_shared(nodes, groupId, groupName, this); + auto ggo = std::make_unique(*this, *group); + + group->setGraphicsObject(std::move(ggo)); + + for (auto &nodePtr : nodes) { + auto node = _nodeGraphicsObjects[nodePtr->nodeId()].get(); + + node->setNodeGroup(group); + } + + std::weak_ptr groupWeakPtr = group; + + _groups[group->id()] = std::move(group); + + return groupWeakPtr; +} + +std::vector BasicGraphicsScene::selectedNodes() const +{ + QList graphicsItems = selectedItems(); + + std::vector result; + result.reserve(graphicsItems.size()); + + for (QGraphicsItem *item : graphicsItems) { + auto ngo = qgraphicsitem_cast(item); + + if (ngo) { + result.push_back(ngo); + } + } + + return result; +} + +std::vector BasicGraphicsScene::selectedGroups() const +{ + if (!_groupingEnabled) + return {}; + + QList graphicsItems = selectedItems(); + + std::vector result; + result.reserve(graphicsItems.size()); + + for (QGraphicsItem *item : graphicsItems) { + auto ngo = qgraphicsitem_cast(item); + + if (ngo) { + result.push_back(ngo); + } + } + + return result; +} + +void BasicGraphicsScene::addNodeToGroup(NodeId nodeId, GroupId groupId) +{ + if (!_groupingEnabled) + return; + + auto groupIt = _groups.find(groupId); + auto nodeIt = _nodeGraphicsObjects.find(nodeId); + if (groupIt == _groups.end() || nodeIt == _nodeGraphicsObjects.end()) + return; + + auto group = groupIt->second; + auto node = nodeIt->second.get(); + group->addNode(node); + node->setNodeGroup(group); +} + +void BasicGraphicsScene::removeNodeFromGroup(NodeId nodeId) +{ + if (!_groupingEnabled) + return; + + auto nodeIt = _nodeGraphicsObjects.find(nodeId); + if (nodeIt == _nodeGraphicsObjects.end()) + return; + + auto group = nodeIt->second->nodeGroup().lock(); + if (group) { + group->removeNode(nodeIt->second.get()); + if (group->empty()) { + _groups.erase(group->id()); + } + } + nodeIt->second->unsetNodeGroup(); + nodeIt->second->lock(false); +} + +std::weak_ptr BasicGraphicsScene::createGroupFromSelection(QString groupName) +{ + if (!_groupingEnabled) + return std::weak_ptr(); + + auto nodes = selectedNodes(); + return createGroup(nodes, groupName); +} + +NodeGraphicsObject &BasicGraphicsScene::loadNodeToMap(QJsonObject nodeJson, bool keepOriginalId) +{ + NodeId newNodeId = InvalidNodeId; + + if (keepOriginalId) { + newNodeId = jsonValueToNodeId(nodeJson["id"]); + } + + if (newNodeId == InvalidNodeId) { + newNodeId = _graphModel.newNodeId(); + nodeJson["id"] = static_cast(newNodeId); + } + + _graphModel.loadNode(nodeJson); + + auto *nodeObject = nodeGraphicsObject(newNodeId); + if (!nodeObject) { + auto graphicsObject = std::make_unique(*this, newNodeId); + nodeObject = graphicsObject.get(); + _nodeGraphicsObjects[newNodeId] = std::move(graphicsObject); + } + + return *nodeObject; +} + +void BasicGraphicsScene::loadConnectionToMap(QJsonObject const &connectionJson, + std::unordered_map const &nodeIdMap) +{ + ConnectionId connId = fromJson(connectionJson); + + auto const outIt = nodeIdMap.find(connId.outNodeId); + auto const inIt = nodeIdMap.find(connId.inNodeId); + + if (outIt == nodeIdMap.end() || inIt == nodeIdMap.end()) { + return; + } + + ConnectionId remapped{outIt->second, connId.outPortIndex, inIt->second, connId.inPortIndex}; + + if (_graphModel.connectionExists(remapped)) { + return; + } + + if (_graphModel.connectionPossible(remapped)) { + _graphModel.addConnection(remapped); + } +} + +std::pair, std::unordered_map> +BasicGraphicsScene::restoreGroup(QJsonObject const &groupJson) +{ + if (!_groupingEnabled) + return {std::weak_ptr(), {}}; + + // since the new nodes will have the same IDs as in the file and the connections + // need these old IDs to be restored, we must create new IDs and map them to the + // old ones so the connections are properly restored + std::unordered_map IDsMap{}; + std::unordered_map nodeIdMap{}; + + std::vector group_children{}; + + QJsonArray nodesJson = groupJson["nodes"].toArray(); + for (const QJsonValueRef nodeJson : nodesJson) { + QJsonObject nodeObject = nodeJson.toObject(); + NodeId const oldNodeId = jsonValueToNodeId(nodeObject["id"]); + + NodeGraphicsObject &nodeRef = loadNodeToMap(nodeObject, false); + NodeId const newNodeId = nodeRef.nodeId(); + + if (oldNodeId != InvalidNodeId) { + nodeIdMap.emplace(oldNodeId, newNodeId); + IDsMap.emplace(static_cast(oldNodeId), static_cast(newNodeId)); + } + + group_children.push_back(&nodeRef); + } + + QJsonArray connectionJsonArray = groupJson["connections"].toArray(); + for (auto connection : connectionJsonArray) { + loadConnectionToMap(connection.toObject(), nodeIdMap); + } + + return std::make_pair(createGroup(group_children, groupJson["name"].toString()), IDsMap); +} + +std::unordered_map> const &BasicGraphicsScene::groups() const +{ + return _groups; +} + +QMenu *BasicGraphicsScene::createStdMenu(QPointF const scenePos) +{ + Q_UNUSED(scenePos); + QMenu *menu = new QMenu(); + + if (_groupingEnabled) { + QMenu *addToGroupMenu = menu->addMenu("Add to group..."); + + for (const auto &groupMap : _groups) { + auto groupPtr = groupMap.second; + auto id = groupMap.first; + + if (!groupPtr) + continue; + + auto groupName = groupPtr->name(); + + QAction *groupAction = addToGroupMenu->addAction(groupName); + + for (const auto &node : selectedNodes()) { + connect(groupAction, &QAction::triggered, [this, id, node]() { + this->addNodeToGroup(node->nodeId(), id); + }); + } + } + + QAction *createGroupAction = menu->addAction("Create group from selection"); + connect(createGroupAction, &QAction::triggered, [this]() { createGroupFromSelection(); }); + } + + QAction *copyAction = menu->addAction("Copy"); + copyAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_C)); + + QAction *cutAction = menu->addAction("Cut"); + cutAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_X)); + + connect(copyAction, &QAction::triggered, this, &BasicGraphicsScene::onCopySelectedObjects); + + connect(cutAction, &QAction::triggered, [this] { + onCopySelectedObjects(); + onDeleteSelectedObjects(); + }); + + menu->setAttribute(Qt::WA_DeleteOnClose); + return menu; +} + +QMenu *BasicGraphicsScene::createGroupMenu(QPointF const scenePos, GroupGraphicsObject *groupGo) +{ + Q_UNUSED(scenePos); + QMenu *menu = new QMenu(); + + QAction *saveGroup = nullptr; + if (_groupingEnabled) { + saveGroup = menu->addAction("Save group..."); + } + + QAction *copyAction = menu->addAction("Copy"); + copyAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_C)); + + QAction *cutAction = menu->addAction("Cut"); + cutAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_X)); + + if (saveGroup) { + connect(saveGroup, &QAction::triggered, [this, groupGo] { + saveGroupFile(groupGo->group().id()); + }); + } + + connect(copyAction, &QAction::triggered, this, &BasicGraphicsScene::onCopySelectedObjects); + + connect(cutAction, &QAction::triggered, [this] { + onCopySelectedObjects(); + onDeleteSelectedObjects(); + }); + + menu->setAttribute(Qt::WA_DeleteOnClose); + return menu; +} + +void BasicGraphicsScene::saveGroupFile(GroupId groupID) +{ + if (!_groupingEnabled) + return; + + QString fileName = QFileDialog::getSaveFileName(nullptr, + tr("Save Node Group"), + QDir::homePath(), + tr("Node Group files (*.group)")); + + if (!fileName.isEmpty()) { + if (!fileName.endsWith("group", Qt::CaseInsensitive)) + fileName += ".group"; + + if (auto groupIt = _groups.find(groupID); groupIt != _groups.end()) { + QFile file(fileName); + if (file.open(QIODevice::WriteOnly)) { + file.write(groupIt->second->saveToFile()); + } else { + qDebug() << "Error saving group file!"; + } + } else { + qDebug() << "Error! Couldn't find group while saving."; + } + } +} + +std::weak_ptr BasicGraphicsScene::loadGroupFile() +{ + if (!_groupingEnabled) + return std::weak_ptr(); + + QString fileName = QFileDialog::getOpenFileName(nullptr, + tr("Open Node Group"), + QDir::currentPath(), + tr("Node Group files (*.group)")); + + if (!QFileInfo::exists(fileName)) + return std::weak_ptr(); + + QFile file(fileName); + + if (!file.open(QIODevice::ReadOnly)) { + qDebug() << "Error loading group file!"; + } + + QDir d = QFileInfo(fileName).absoluteDir(); + QString absolute = d.absolutePath(); + QDir::setCurrent(absolute); + + QByteArray wholeFile = file.readAll(); + + const QJsonObject fileJson = QJsonDocument::fromJson(wholeFile).object(); + + return restoreGroup(fileJson).first; +} + +GroupId BasicGraphicsScene::nextGroupId() +{ + if (_nextGroupId == InvalidGroupId) { + throw std::runtime_error("No available group identifiers"); + } + + while (_groups.count(_nextGroupId) != 0) { + ++_nextGroupId; + if (_nextGroupId == InvalidGroupId) { + throw std::runtime_error("No available group identifiers"); + } + } + + GroupId const newId = _nextGroupId; + ++_nextGroupId; + return newId; +} + } // namespace QtNodes diff --git a/src/DataFlowGraphModel.cpp b/src/DataFlowGraphModel.cpp index 187375b47..263b43880 100644 --- a/src/DataFlowGraphModel.cpp +++ b/src/DataFlowGraphModel.cpp @@ -63,48 +63,51 @@ NodeId DataFlowGraphModel::addNode(QString const nodeType) { std::unique_ptr model = _registry->create(nodeType); - if (!model) - return InvalidNodeId; - - NodeId newId = newNodeId(); + if (model) { + NodeId newId = newNodeId(); - connect(model.get(), &NodeDelegateModel::dataUpdated, [newId, this](PortIndex const portIndex) { - onOutPortDataUpdated(newId, portIndex); - }); + connect(model.get(), + &NodeDelegateModel::dataUpdated, + [newId, this](PortIndex const portIndex) { + onOutPortDataUpdated(newId, portIndex); + }); - connect(model.get(), - &NodeDelegateModel::portsAboutToBeDeleted, - this, - [newId, this](PortType const portType, PortIndex const first, PortIndex const last) { - portsAboutToBeDeleted(newId, portType, first, last); - }); + connect(model.get(), + &NodeDelegateModel::portsAboutToBeDeleted, + this, + [newId, this](PortType const portType, PortIndex const first, PortIndex const last) { + portsAboutToBeDeleted(newId, portType, first, last); + }); - connect(model.get(), &NodeDelegateModel::portsDeleted, this, &DataFlowGraphModel::portsDeleted); + connect(model.get(), + &NodeDelegateModel::portsDeleted, + this, + &DataFlowGraphModel::portsDeleted); - connect(model.get(), - &NodeDelegateModel::portsAboutToBeInserted, - this, - [newId, this](PortType const portType, PortIndex const first, PortIndex const last) { - portsAboutToBeInserted(newId, portType, first, last); - }); + connect(model.get(), + &NodeDelegateModel::portsAboutToBeInserted, + this, + [newId, this](PortType const portType, PortIndex const first, PortIndex const last) { + portsAboutToBeInserted(newId, portType, first, last); + }); - connect(model.get(), - &NodeDelegateModel::portsInserted, - this, - &DataFlowGraphModel::portsInserted); + connect(model.get(), + &NodeDelegateModel::portsInserted, + this, + &DataFlowGraphModel::portsInserted); - connect(model.get(), &NodeDelegateModel::requestNodeUpdate, this, [newId, this]() { - Q_EMIT nodeUpdated(newId); - }); + connect(model.get(), &NodeDelegateModel::requestNodeUpdate, this, [newId, this]() { + Q_EMIT nodeUpdated(newId); + }); - _models[newId] = std::move(model); + _models[newId] = std::move(model); - _labels[newId] = _models[newId]->label(); - _labelsVisible[newId] = _models[newId]->labelVisible(); + Q_EMIT nodeCreated(newId); - Q_EMIT nodeCreated(newId); + return newId; + } - return newId; + return InvalidNodeId; } bool DataFlowGraphModel::connectionPossible(ConnectionId const connectionId) const @@ -265,7 +268,7 @@ QVariant DataFlowGraphModel::nodeData(NodeId nodeId, NodeRole role) const break; case NodeRole::Style: { - auto style = StyleCollection::nodeStyle(); + auto style = _models.at(nodeId)->nodeStyle(); result = style.toJson().toVariantMap(); } break; @@ -296,18 +299,6 @@ QVariant DataFlowGraphModel::nodeData(NodeId nodeId, NodeRole role) const result = QVariant::fromValue(validationState); } break; - case NodeRole::LabelVisible: - result = _labelsVisible.at(nodeId); - break; - - case NodeRole::Label: - result = _labels.at(nodeId); - break; - - case NodeRole::LabelEditable: - result = model->labelEditable(); - break; - case NodeRole::ProcessingStatus: { auto processingStatus = model->processingStatus(); result = QVariant::fromValue(processingStatus); @@ -391,21 +382,6 @@ bool DataFlowGraphModel::setNodeData(NodeId nodeId, NodeRole role, QVariant valu } Q_EMIT nodeUpdated(nodeId); } break; - - case NodeRole::LabelVisible: { - _labelsVisible[nodeId] = value.toBool(); - Q_EMIT nodeUpdated(nodeId); - result = true; - } break; - - case NodeRole::Label: { - _labels[nodeId] = value.toString(); - Q_EMIT nodeUpdated(nodeId); - result = true; - } break; - - case NodeRole::LabelEditable: - break; } return result; @@ -513,8 +489,6 @@ bool DataFlowGraphModel::deleteNode(NodeId const nodeId) } _nodeGeometryData.erase(nodeId); - _labels.erase(nodeId); - _labelsVisible.erase(nodeId); _models.erase(nodeId); Q_EMIT nodeDeleted(nodeId); @@ -530,9 +504,6 @@ QJsonObject DataFlowGraphModel::saveNode(NodeId const nodeId) const nodeJson["internal-data"] = _models.at(nodeId)->save(); - nodeJson["label"] = _labels.at(nodeId); - nodeJson["labelVisible"] = _labelsVisible.at(nodeId); - { QPointF const pos = nodeData(nodeId, NodeRole::Position).value(); @@ -615,6 +586,7 @@ void DataFlowGraphModel::loadNode(QJsonObject const &nodeJson) &NodeDelegateModel::portsInserted, this, &DataFlowGraphModel::portsInserted); + connect(model.get(), &NodeDelegateModel::requestNodeUpdate, this, [restoredNodeId, this]() { Q_EMIT nodeUpdated(restoredNodeId); }); @@ -628,13 +600,7 @@ void DataFlowGraphModel::loadNode(QJsonObject const &nodeJson) setNodeData(restoredNodeId, NodeRole::Position, pos); - auto *restoredModel = _models[restoredNodeId].get(); - _labels[restoredNodeId] = nodeJson["label"].toString(restoredModel->label()); - _labelsVisible[restoredNodeId] = nodeJson.contains("labelVisible") - ? nodeJson["labelVisible"].toBool() - : restoredModel->labelVisible(); - - restoredModel->load(internalDataJson); + _models[restoredNodeId]->load(internalDataJson); } else { throw std::logic_error(std::string("No registered model with name ") + delegateModelName.toLocal8Bit().data()); diff --git a/src/DataFlowGraphicsScene.cpp b/src/DataFlowGraphicsScene.cpp index 32970608e..0a028cc1d 100644 --- a/src/DataFlowGraphicsScene.cpp +++ b/src/DataFlowGraphicsScene.cpp @@ -21,11 +21,51 @@ #include #include #include +#include +#include +#include #include #include #include +#include +namespace { + +using QtNodes::GroupId; +using QtNodes::InvalidGroupId; + +GroupId jsonValueToGroupId(QJsonValue const &value) +{ + if (value.isDouble()) { + return static_cast(value.toInt()); + } + + if (value.isString()) { + auto const textValue = value.toString(); + + bool ok = false; + auto const numericValue = textValue.toULongLong(&ok, 10); + if (ok) { + return static_cast(numericValue); + } + + QUuid uuidValue(textValue); + if (!uuidValue.isNull()) { + auto const bytes = uuidValue.toRfc4122(); + if (bytes.size() >= static_cast(sizeof(quint32))) { + QDataStream stream(bytes); + quint32 value32 = 0U; + stream >> value32; + return static_cast(value32); + } + } + } + + return InvalidGroupId; +} + +} // namespace namespace QtNodes { @@ -158,7 +198,32 @@ bool DataFlowGraphicsScene::save() const QFile file(fileName); if (file.open(QIODevice::WriteOnly)) { - file.write(QJsonDocument(_graphModel.save()).toJson()); + QJsonObject sceneJson = _graphModel.save(); + + QJsonArray groupsJsonArray; + for (auto const &[groupId, groupPtr] : groups()) { + if (!groupPtr) + continue; + + QJsonObject groupJson; + groupJson["id"] = static_cast(groupId); + groupJson["name"] = groupPtr->name(); + + QJsonArray nodeIdsJson; + for (NodeId const nodeId : groupPtr->nodeIDs()) { + nodeIdsJson.append(static_cast(nodeId)); + } + groupJson["nodes"] = nodeIdsJson; + groupJson["locked"] = groupPtr->groupGraphicsObject().locked(); + + groupsJsonArray.append(groupJson); + } + + if (!groupsJsonArray.isEmpty()) { + sceneJson["groups"] = groupsJsonArray; + } + + file.write(QJsonDocument(sceneJson).toJson()); return true; } } @@ -184,7 +249,45 @@ bool DataFlowGraphicsScene::load() QByteArray const wholeFile = file.readAll(); - _graphModel.load(QJsonDocument::fromJson(wholeFile).object()); + QJsonParseError parseError{}; + QJsonDocument const sceneDocument = QJsonDocument::fromJson(wholeFile, &parseError); + if (parseError.error != QJsonParseError::NoError || !sceneDocument.isObject()) + return false; + + QJsonObject const sceneJson = sceneDocument.object(); + + _graphModel.load(sceneJson); + + if (sceneJson.contains("groups")) { + QJsonArray const groupsJsonArray = sceneJson["groups"].toArray(); + + for (QJsonValue groupValue : groupsJsonArray) { + QJsonObject const groupObject = groupValue.toObject(); + + QJsonArray const nodeIdsJson = groupObject["nodes"].toArray(); + std::vector groupNodes; + groupNodes.reserve(nodeIdsJson.size()); + + for (QJsonValue idValue : nodeIdsJson) { + NodeId const nodeId = static_cast(idValue.toInt()); + if (auto *nodeObject = nodeGraphicsObject(nodeId)) { + groupNodes.push_back(nodeObject); + } + } + + if (groupNodes.empty()) + continue; + + QString const groupName = groupObject["name"].toString(); + GroupId const groupId = jsonValueToGroupId(groupObject["id"]); + + auto const groupWeak = createGroup(groupNodes, groupName, groupId); + if (auto group = groupWeak.lock()) { + bool const locked = groupObject["locked"].toBool(true); + group->groupGraphicsObject().lock(locked); + } + } + } Q_EMIT sceneLoaded(); diff --git a/src/DefaultHorizontalNodeGeometry.cpp b/src/DefaultHorizontalNodeGeometry.cpp index aa2f9c32f..586795117 100644 --- a/src/DefaultHorizontalNodeGeometry.cpp +++ b/src/DefaultHorizontalNodeGeometry.cpp @@ -1,4 +1,5 @@ #include "DefaultHorizontalNodeGeometry.hpp" + #include "AbstractGraphModel.hpp" #include "NodeData.hpp" @@ -11,7 +12,7 @@ namespace QtNodes { DefaultHorizontalNodeGeometry::DefaultHorizontalNodeGeometry(AbstractGraphModel &graphModel) : AbstractNodeGeometry(graphModel) , _portSize(20) - , _portSpacing(10) + , _portSpasing(10) , _fontMetrics(QFont()) , _boldFontMetrics(QFont()) { @@ -26,7 +27,7 @@ QRectF DefaultHorizontalNodeGeometry::boundingRect(NodeId const nodeId) const { QSize s = size(nodeId); - qreal marginSize = 2.0 * _portSpacing; + qreal marginSize = 2.0 * _portSpasing; QMargins margins(marginSize, marginSize, marginSize, marginSize); QRectF r(QPointF(0, 0), s); @@ -48,16 +49,11 @@ void DefaultHorizontalNodeGeometry::recomputeSize(NodeId const nodeId) const } QRectF const capRect = captionRect(nodeId); - QRectF const lblRect = labelRect(nodeId); height += capRect.height(); - if (!lblRect.isNull()) { - height += lblRect.height(); - height += _portSpacing / 2; - } - height += _portSpacing; // space above caption - height += _portSpacing; // space below caption + height += _portSpasing; // space above caption + height += _portSpasing; // space below caption QVariant var = _graphModel.nodeData(nodeId, NodeRole::ProcessingStatus); auto processingStatusValue = var.value(); @@ -68,17 +64,13 @@ void DefaultHorizontalNodeGeometry::recomputeSize(NodeId const nodeId) const unsigned int inPortWidth = maxPortsTextAdvance(nodeId, PortType::In); unsigned int outPortWidth = maxPortsTextAdvance(nodeId, PortType::Out); - unsigned int width = inPortWidth + outPortWidth + 4 * _portSpacing; + unsigned int width = inPortWidth + outPortWidth + 4 * _portSpasing; if (auto w = _graphModel.nodeData(nodeId, NodeRole::Widget)) { width += w->width(); } - unsigned int textWidth = static_cast(capRect.width()); - if (!lblRect.isNull()) - textWidth = std::max(textWidth, static_cast(lblRect.width())); - - width = std::max(width, textWidth + 2 * _portSpacing); + width = std::max(width, static_cast(capRect.width()) + 2 * _portSpasing); QSize size(width, height); @@ -89,20 +81,14 @@ QPointF DefaultHorizontalNodeGeometry::portPosition(NodeId const nodeId, PortType const portType, PortIndex const portIndex) const { - unsigned int const step = _portSize + _portSpacing; + unsigned int const step = _portSize + _portSpasing; QPointF result; double totalHeight = 0.0; totalHeight += captionRect(nodeId).height(); - - if (_graphModel.nodeData(nodeId, NodeRole::LabelVisible)) { - totalHeight += labelRect(nodeId).height(); - totalHeight += _portSpacing / 2.0; - } - - totalHeight += _portSpacing; + totalHeight += _portSpasing; totalHeight += step * portIndex; totalHeight += step / 2.0; @@ -145,11 +131,11 @@ QPointF DefaultHorizontalNodeGeometry::portTextPosition(NodeId const nodeId, switch (portType) { case PortType::In: - p.setX(_portSpacing); + p.setX(_portSpasing); break; case PortType::Out: - p.setX(size.width() - _portSpacing - rect.width()); + p.setX(size.width() - _portSpasing - rect.width()); break; default: @@ -172,48 +158,8 @@ QRectF DefaultHorizontalNodeGeometry::captionRect(NodeId const nodeId) const QPointF DefaultHorizontalNodeGeometry::captionPosition(NodeId const nodeId) const { QSize size = _graphModel.nodeData(nodeId, NodeRole::Size); - - QRectF cap = captionRect(nodeId); - QRectF lbl = labelRect(nodeId); - - double y = 0.5 * _portSpacing + cap.height(); - y += _portSpacing / 2.0 + lbl.height(); - - return QPointF(0.5 * (size.width() - captionRect(nodeId).width()), y); -} - -QRectF DefaultHorizontalNodeGeometry::labelRect(NodeId const nodeId) const -{ - if (!_graphModel.nodeData(nodeId, NodeRole::LabelVisible)) - return QRect(); - - QString nickname = _graphModel.nodeData(nodeId, NodeRole::Label); - - QRectF nickRect = _boldFontMetrics.boundingRect(nickname); - - nickRect.setWidth(nickRect.width() * 0.5); - nickRect.setHeight(nickRect.height() * 0.5); - - return nickRect; -} - -QPointF DefaultHorizontalNodeGeometry::labelPosition(NodeId const nodeId) const -{ - QRectF cap = captionRect(nodeId); - QRectF lbl = labelRect(nodeId); - - double y = 0.5 * _portSpacing + cap.height(); - y += _portSpacing / 2.0 + lbl.height(); - - if (!_graphModel.nodeData(nodeId, NodeRole::CaptionVisible)) { - return QPointF(captionPosition(nodeId).x() - + 0.5 * (captionRect(nodeId).width() - 2 * labelRect(nodeId).width()), - y); - } - - return QPointF(captionPosition(nodeId).x() - + 0.5 * (captionRect(nodeId).width() - 2 * labelRect(nodeId).width()), - 0.5 * _portSpacing + captionRect(nodeId).height()); + return QPointF(0.5 * (size.width() - captionRect(nodeId).width()), + 0.5 * _portSpasing + captionRect(nodeId).height()); } QPointF DefaultHorizontalNodeGeometry::widgetPosition(NodeId const nodeId) const @@ -221,17 +167,15 @@ QPointF DefaultHorizontalNodeGeometry::widgetPosition(NodeId const nodeId) const QSize size = _graphModel.nodeData(nodeId, NodeRole::Size); unsigned int captionHeight = captionRect(nodeId).height(); - if (_graphModel.nodeData(nodeId, NodeRole::LabelVisible)) - captionHeight += labelRect(nodeId).height() + _portSpacing / 2; if (auto w = _graphModel.nodeData(nodeId, NodeRole::Widget)) { // If the widget wants to use as much vertical space as possible, // place it immediately after the caption. if (w->sizePolicy().verticalPolicy() & QSizePolicy::ExpandFlag) { - return QPointF(2.0 * _portSpacing + maxPortsTextAdvance(nodeId, PortType::In), - _portSpacing + captionHeight); + return QPointF(2.0 * _portSpasing + maxPortsTextAdvance(nodeId, PortType::In), + _portSpasing + captionHeight); } else { - return QPointF(2.0 * _portSpacing + maxPortsTextAdvance(nodeId, PortType::In), + return QPointF(2.0 * _portSpasing + maxPortsTextAdvance(nodeId, PortType::In), (captionHeight + size.height() - w->height()) / 2.0); } } @@ -244,7 +188,7 @@ QRect DefaultHorizontalNodeGeometry::resizeHandleRect(NodeId const nodeId) const unsigned int rectSize = 7; - return QRect(size.width() - _portSpacing, size.height() - _portSpacing, rectSize, rectSize); + return QRect(size.width() - _portSpasing, size.height() - _portSpasing, rectSize, rectSize); } QRectF DefaultHorizontalNodeGeometry::portTextRect(NodeId const nodeId, @@ -270,7 +214,7 @@ unsigned int DefaultHorizontalNodeGeometry::maxVerticalPortsExtent(NodeId const PortCount nOutPorts = _graphModel.nodeData(nodeId, NodeRole::OutPortCount); unsigned int maxNumOfEntries = std::max(nInPorts, nOutPorts); - unsigned int step = _portSize + _portSpacing; + unsigned int step = _portSize + _portSpasing; return step * maxNumOfEntries; } diff --git a/src/DefaultNodePainter.cpp b/src/DefaultNodePainter.cpp index 66180fe1c..1d90ae5c9 100644 --- a/src/DefaultNodePainter.cpp +++ b/src/DefaultNodePainter.cpp @@ -38,8 +38,6 @@ void DefaultNodePainter::paint(QPainter *painter, NodeGraphicsObject &ngo) const drawResizeRect(painter, ngo); drawValidationIcon(painter, ngo); - - drawNodeLabel(painter, ngo); } void DefaultNodePainter::drawNodeRect(QPainter *painter, NodeGraphicsObject &ngo) const @@ -57,43 +55,47 @@ void DefaultNodePainter::drawNodeRect(QPainter *painter, NodeGraphicsObject &ngo NodeStyle nodeStyle(json.object()); QVariant var = model.nodeData(nodeId, NodeRole::ValidationState); + bool invalid = false; QColor color = ngo.isSelected() ? nodeStyle.SelectedBoundaryColor : nodeStyle.NormalBoundaryColor; - auto validationState = NodeValidationState::State::Valid; if (var.canConvert()) { auto state = var.value(); - validationState = state._state; - switch (validationState) { - case NodeValidationState::State::Error: + switch (state._state) { + case NodeValidationState::State::Error: { + invalid = true; color = nodeStyle.ErrorColor; - break; - case NodeValidationState::State::Warning: + } break; + case NodeValidationState::State::Warning: { + invalid = true; color = nodeStyle.WarningColor; break; default: break; } + } } - float penWidth = ngo.nodeState().hovered() ? nodeStyle.HoveredPenWidth : nodeStyle.PenWidth; - if (validationState != NodeValidationState::State::Valid) { - float factor = (validationState == NodeValidationState::State::Error) ? 3.0f : 2.0f; - penWidth *= factor; + if (ngo.nodeState().hovered()) { + QPen p(color, nodeStyle.HoveredPenWidth); + painter->setPen(p); + } else { + QPen p(color, nodeStyle.PenWidth); + painter->setPen(p); } - QPen p(color, penWidth); - painter->setPen(p); - - QLinearGradient gradient(QPointF(0.0, 0.0), QPointF(2.0, size.height())); - gradient.setColorAt(0.0, nodeStyle.GradientColor0); - gradient.setColorAt(0.10, nodeStyle.GradientColor1); - gradient.setColorAt(0.90, nodeStyle.GradientColor2); - gradient.setColorAt(1.0, nodeStyle.GradientColor3); - - painter->setBrush(gradient); + if (invalid) { + painter->setBrush(color); + } else { + QLinearGradient gradient(QPointF(0.0, 0.0), QPointF(2.0, size.height())); + gradient.setColorAt(0.0, nodeStyle.GradientColor0); + gradient.setColorAt(0.10, nodeStyle.GradientColor1); + gradient.setColorAt(0.90, nodeStyle.GradientColor2); + gradient.setColorAt(1.0, nodeStyle.GradientColor3); + painter->setBrush(gradient); + } QRectF boundary(0, 0, size.width(), size.height()); double const radius = 3.0; @@ -224,67 +226,19 @@ void DefaultNodePainter::drawNodeCaption(QPainter *painter, NodeGraphicsObject & if (!model.nodeData(nodeId, NodeRole::CaptionVisible).toBool()) return; - QString const nickname = model.nodeData(nodeId, NodeRole::Label).toString(); QString const name = model.nodeData(nodeId, NodeRole::Caption).toString(); - QFont f = painter->font(); - f.setBold(nickname.isEmpty()); - f.setItalic(!nickname.isEmpty()); - - QFontMetricsF metrics(f); - - QRectF bounding = metrics.boundingRect(name); - QRectF capRect = geometry.captionRect(nodeId); - QPointF capPos = geometry.captionPosition(nodeId); - double centerX = capPos.x() + capRect.width() / 2.0; - - QPointF position(centerX - bounding.width() / 2.0, capPos.y()); - - QJsonDocument json = QJsonDocument::fromVariant(model.nodeData(nodeId, NodeRole::Style)); - NodeStyle nodeStyle(json.object()); - - painter->setFont(f); - painter->setPen(nodeStyle.FontColor); - painter->drawText(position, name); - - f.setBold(false); - f.setItalic(false); - painter->setFont(f); -} - -void DefaultNodePainter::drawNodeLabel(QPainter *painter, NodeGraphicsObject &ngo) const -{ - AbstractGraphModel &model = ngo.graphModel(); - NodeId const nodeId = ngo.nodeId(); - AbstractNodeGeometry &geometry = ngo.nodeScene()->nodeGeometry(); - - if (!model.nodeData(nodeId, NodeRole::LabelVisible).toBool()) - return; - - QString const nickname = model.nodeData(nodeId, NodeRole::Label).toString(); - QFont f = painter->font(); f.setBold(true); - f.setItalic(false); - - QFontMetricsF metrics(f); - QRectF bounding = metrics.boundingRect(nickname); - QRectF capRect = geometry.captionRect(nodeId); - QPointF capPos = geometry.captionPosition(nodeId); - double centerX = capPos.x() + capRect.width() / 2.0; - - double textHeight = metrics.height(); - double y = capPos.y() - textHeight - 2.0; - - QPointF position(centerX - bounding.width() / 2.0, y); + QPointF position = geometry.captionPosition(nodeId); QJsonDocument json = QJsonDocument::fromVariant(model.nodeData(nodeId, NodeRole::Style)); NodeStyle nodeStyle(json.object()); painter->setFont(f); painter->setPen(nodeStyle.FontColor); - painter->drawText(position, nickname); + painter->drawText(position, name); f.setBold(false); painter->setFont(f); @@ -356,21 +310,32 @@ void DefaultNodePainter::drawProcessingIndicator(QPainter *painter, NodeGraphics if (!delegate) return; + // Skip if status is NoStatus + if (delegate->processingStatus() == NodeProcessingStatus::NoStatus) + return; + AbstractNodeGeometry &geometry = ngo.nodeScene()->nodeGeometry(); QSize size = geometry.size(nodeId); QPixmap pixmap = delegate->processingStatusIcon(); - NodeStyle nodeStyle = delegate->nodeStyle(); + if (pixmap.isNull()) + return; - ProcessingIconStyle iconStyle = nodeStyle.processingIconStyle; + ProcessingIconStyle const &iconStyle = delegate->nodeStyle().processingIconStyle; qreal iconSize = iconStyle._size; qreal margin = iconStyle._margin; - qreal x = margin; + // Determine position, avoiding conflict with resize handle + ProcessingIconPos pos = iconStyle._pos; + bool isResizable = model.nodeFlags(nodeId) & NodeFlag::Resizable; + if (isResizable && pos == ProcessingIconPos::BottomRight) { + pos = ProcessingIconPos::BottomLeft; + } - if (iconStyle._pos == ProcessingIconPos::BottomRight) { + qreal x = margin; + if (pos == ProcessingIconPos::BottomRight) { x = size.width() - iconSize - margin; } @@ -404,25 +369,16 @@ void DefaultNodePainter::drawValidationIcon(QPainter *painter, NodeGraphicsObjec QColor color = (state._state == NodeValidationState::State::Error) ? nodeStyle.ErrorColor : nodeStyle.WarningColor; - QPointF center(size.width(), 0.0); - center += QPointF(iconSize.width() / 2.0, -iconSize.height() / 2.0); - - painter->save(); - - // Draw a colored circle behind the icon to highlight validation issues - painter->setPen(Qt::NoPen); - painter->setBrush(color); - painter->drawEllipse(center, iconSize.width() / 2.0 + 2.0, iconSize.height() / 2.0 + 2.0); - QPainter imgPainter(&pixmap); imgPainter.setCompositionMode(QPainter::CompositionMode_SourceIn); - imgPainter.fillRect(pixmap.rect(), nodeStyle.FontColor); + imgPainter.fillRect(pixmap.rect(), color); imgPainter.end(); + QPointF center(size.width(), 0.0); + center += QPointF(iconSize.width() / 2.0, -iconSize.height() / 2.0); + painter->drawPixmap(center.toPoint() - QPoint(iconSize.width() / 2, iconSize.height() / 2), pixmap); - - painter->restore(); } } // namespace QtNodes diff --git a/src/DefaultVerticalNodeGeometry.cpp b/src/DefaultVerticalNodeGeometry.cpp index 589f5cdcb..f20617f25 100644 --- a/src/DefaultVerticalNodeGeometry.cpp +++ b/src/DefaultVerticalNodeGeometry.cpp @@ -12,7 +12,7 @@ namespace QtNodes { DefaultVerticalNodeGeometry::DefaultVerticalNodeGeometry(AbstractGraphModel &graphModel) : AbstractNodeGeometry(graphModel) , _portSize(20) - , _portSpacing(10) + , _portSpasing(10) , _fontMetrics(QFont()) , _boldFontMetrics(QFont()) { @@ -27,7 +27,7 @@ QRectF DefaultVerticalNodeGeometry::boundingRect(NodeId const nodeId) const { QSize s = size(nodeId); - qreal marginSize = 2.0 * _portSpacing; + qreal marginSize = 2.0 * _portSpasing; QMargins margins(marginSize, marginSize, marginSize, marginSize); QRectF r(QPointF(0, 0), s); @@ -42,23 +42,18 @@ QSize DefaultVerticalNodeGeometry::size(NodeId const nodeId) const void DefaultVerticalNodeGeometry::recomputeSize(NodeId const nodeId) const { - unsigned int height = _portSpacing; // maxHorizontalPortsExtent(nodeId); + unsigned int height = _portSpasing; // maxHorizontalPortsExtent(nodeId); if (auto w = _graphModel.nodeData(nodeId, NodeRole::Widget)) { height = std::max(height, static_cast(w->height())); } QRectF const capRect = captionRect(nodeId); - QRectF const lblRect = labelRect(nodeId); height += capRect.height(); - if (!lblRect.isNull()) { - height += lblRect.height(); - height += _portSpacing / 2; - } - height += _portSpacing; - height += _portSpacing; + height += _portSpasing; + height += _portSpasing; PortCount nInPorts = _graphModel.nodeData(nodeId, NodeRole::InPortCount); PortCount nOutPorts = _graphModel.nodeData(nodeId, NodeRole::OutPortCount); @@ -72,11 +67,11 @@ void DefaultVerticalNodeGeometry::recomputeSize(NodeId const nodeId) const unsigned int outPortWidth = maxPortsTextAdvance(nodeId, PortType::Out); unsigned int totalInPortsWidth = nInPorts > 0 - ? inPortWidth * nInPorts + _portSpacing * (nInPorts - 1) + ? inPortWidth * nInPorts + _portSpasing * (nInPorts - 1) : 0; unsigned int totalOutPortsWidth = nOutPorts > 0 ? outPortWidth * nOutPorts - + _portSpacing * (nOutPorts - 1) + + _portSpasing * (nOutPorts - 1) : 0; unsigned int width = std::max(totalInPortsWidth, totalOutPortsWidth); @@ -85,14 +80,10 @@ void DefaultVerticalNodeGeometry::recomputeSize(NodeId const nodeId) const width = std::max(width, static_cast(w->width())); } - unsigned int textWidth = static_cast(capRect.width()); - if (!lblRect.isNull()) - textWidth = std::max(textWidth, static_cast(lblRect.width())); - - width = std::max(width, textWidth); + width = std::max(width, static_cast(capRect.width())); - width += _portSpacing; - width += _portSpacing; + width += _portSpasing; + width += _portSpasing; QSize size(width, height); @@ -109,7 +100,7 @@ QPointF DefaultVerticalNodeGeometry::portPosition(NodeId const nodeId, switch (portType) { case PortType::In: { - unsigned int inPortWidth = maxPortsTextAdvance(nodeId, PortType::In) + _portSpacing; + unsigned int inPortWidth = maxPortsTextAdvance(nodeId, PortType::In) + _portSpasing; PortCount nInPorts = _graphModel.nodeData(nodeId, NodeRole::InPortCount); @@ -123,7 +114,7 @@ QPointF DefaultVerticalNodeGeometry::portPosition(NodeId const nodeId, } case PortType::Out: { - unsigned int outPortWidth = maxPortsTextAdvance(nodeId, PortType::Out) + _portSpacing; + unsigned int outPortWidth = maxPortsTextAdvance(nodeId, PortType::Out) + _portSpasing; PortCount nOutPorts = _graphModel.nodeData(nodeId, NodeRole::OutPortCount); double x = (size.width() - (nOutPorts - 1) * outPortWidth) / 2.0 + portIndex * outPortWidth; @@ -185,56 +176,26 @@ QPointF DefaultVerticalNodeGeometry::captionPosition(NodeId const nodeId) const QSize size = _graphModel.nodeData(nodeId, NodeRole::Size); unsigned int step = portCaptionsHeight(nodeId, PortType::In); - step += _portSpacing; + step += _portSpasing; auto rect = captionRect(nodeId); return QPointF(0.5 * (size.width() - rect.width()), step + rect.height()); } -QPointF DefaultVerticalNodeGeometry::labelPosition(const NodeId nodeId) const -{ - QSize size = _graphModel.nodeData(nodeId, NodeRole::Size); - - QRectF rect = labelRect(nodeId); - - unsigned int step = portCaptionsHeight(nodeId, PortType::In); - step += _portSpacing; - step += captionRect(nodeId).height(); - step += _portSpacing / 2; - - return QPointF(0.5 * (size.width() - rect.width()), step + rect.height()); -} - -QRectF DefaultVerticalNodeGeometry::labelRect(NodeId const nodeId) const -{ - if (!_graphModel.nodeData(nodeId, NodeRole::LabelVisible)) - return QRectF(); - - QString nickname = _graphModel.nodeData(nodeId, NodeRole::Label); - - QRectF rect = _boldFontMetrics.boundingRect(nickname); - rect.setWidth(rect.width() * 0.5); - rect.setHeight(rect.height() * 0.5); - - return rect; -} - QPointF DefaultVerticalNodeGeometry::widgetPosition(NodeId const nodeId) const { QSize size = _graphModel.nodeData(nodeId, NodeRole::Size); unsigned int captionHeight = captionRect(nodeId).height(); - if (_graphModel.nodeData(nodeId, NodeRole::LabelVisible)) - captionHeight += labelRect(nodeId).height() + _portSpacing / 2; if (auto w = _graphModel.nodeData(nodeId, NodeRole::Widget)) { // If the widget wants to use as much vertical space as possible, // place it immediately after the caption. if (w->sizePolicy().verticalPolicy() & QSizePolicy::ExpandFlag) { - return QPointF(_portSpacing + maxPortsTextAdvance(nodeId, PortType::In), captionHeight); + return QPointF(_portSpasing + maxPortsTextAdvance(nodeId, PortType::In), captionHeight); } else { - return QPointF(_portSpacing + maxPortsTextAdvance(nodeId, PortType::In), + return QPointF(_portSpasing + maxPortsTextAdvance(nodeId, PortType::In), (captionHeight + size.height() - w->height()) / 2.0); } } @@ -273,7 +234,7 @@ unsigned int DefaultVerticalNodeGeometry::maxHorizontalPortsExtent(NodeId const PortCount nOutPorts = _graphModel.nodeData(nodeId, NodeRole::OutPortCount); unsigned int maxNumOfEntries = std::max(nInPorts, nOutPorts); - unsigned int step = _portSize + _portSpacing; + unsigned int step = _portSize + _portSpasing; return step * maxNumOfEntries; } @@ -323,7 +284,7 @@ unsigned int DefaultVerticalNodeGeometry::portCaptionsHeight(NodeId const nodeId PortCount nInPorts = _graphModel.nodeData(nodeId, NodeRole::InPortCount); for (PortIndex i = 0; i < nInPorts; ++i) { if (_graphModel.portData(nodeId, PortType::In, i, PortRole::CaptionVisible)) { - h += _portSpacing; + h += _portSpasing; break; } } @@ -334,7 +295,7 @@ unsigned int DefaultVerticalNodeGeometry::portCaptionsHeight(NodeId const nodeId PortCount nOutPorts = _graphModel.nodeData(nodeId, NodeRole::OutPortCount); for (PortIndex i = 0; i < nOutPorts; ++i) { if (_graphModel.portData(nodeId, PortType::Out, i, PortRole::CaptionVisible)) { - h += _portSpacing; + h += _portSpasing; break; } } diff --git a/src/GraphicsView.cpp b/src/GraphicsView.cpp index cd19581eb..1516aba43 100644 --- a/src/GraphicsView.cpp +++ b/src/GraphicsView.cpp @@ -2,13 +2,13 @@ #include "BasicGraphicsScene.hpp" #include "ConnectionGraphicsObject.hpp" +#include "DataFlowGraphModel.hpp" #include "Definitions.hpp" +#include "GroupGraphicsObject.hpp" #include "NodeGraphicsObject.hpp" #include "StyleCollection.hpp" #include "UndoCommands.hpp" -#include - #include #include @@ -26,12 +26,15 @@ #include using QtNodes::BasicGraphicsScene; +using QtNodes::DataFlowGraphModel; using QtNodes::GraphicsView; +using QtNodes::NodeGraphicsObject; GraphicsView::GraphicsView(QWidget *parent) : QGraphicsView(parent) , _clearSelectionAction(Q_NULLPTR) , _deleteSelectionAction(Q_NULLPTR) + , _cutSelectionAction(Q_NULLPTR) , _duplicateSelectionAction(Q_NULLPTR) , _copySelectionAction(Q_NULLPTR) , _pasteAction(Q_NULLPTR) @@ -78,6 +81,20 @@ QAction *GraphicsView::deleteSelectionAction() const void GraphicsView::setScene(BasicGraphicsScene *scene) { QGraphicsView::setScene(scene); + if (!scene) { + // Clear actions. + delete _clearSelectionAction; + delete _deleteSelectionAction; + delete _duplicateSelectionAction; + delete _copySelectionAction; + delete _pasteAction; + _clearSelectionAction = nullptr; + _deleteSelectionAction = nullptr; + _duplicateSelectionAction = nullptr; + _copySelectionAction = nullptr; + _pasteAction = nullptr; + return; + } { // setup actions @@ -104,6 +121,21 @@ void GraphicsView::setScene(BasicGraphicsScene *scene) addAction(_deleteSelectionAction); } + { + delete _cutSelectionAction; + _cutSelectionAction = new QAction(QStringLiteral("Cut Selection"), this); + _cutSelectionAction->setShortcutContext(Qt::ShortcutContext::WidgetShortcut); + _cutSelectionAction->setShortcut(QKeySequence(QKeySequence::Cut)); + _cutSelectionAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_X)); + _cutSelectionAction->setAutoRepeat(false); + connect(_cutSelectionAction, &QAction::triggered, [this] { + onCopySelectedObjects(); + onDeleteSelectedObjects(); + }); + + addAction(_cutSelectionAction); + } + { delete _duplicateSelectionAction; _duplicateSelectionAction = new QAction(QStringLiteral("Duplicate Selection"), this); @@ -169,18 +201,38 @@ void GraphicsView::centerScene() void GraphicsView::contextMenuEvent(QContextMenuEvent *event) { - if (itemAt(event->pos())) { - QGraphicsView::contextMenuEvent(event); - return; - } + QGraphicsView::contextMenuEvent(event); + QMenu *menu = nullptr; + const QPointF scenePos = mapToScene(event->pos()); + + auto clickedItems = items(event->pos()); - auto const scenePos = mapToScene(event->pos()); + for (QGraphicsItem *item : clickedItems) { + if (auto *nodeItem = qgraphicsitem_cast(item)) { + Q_UNUSED(nodeItem); + menu = nodeScene()->createStdMenu(scenePos); + break; + } - QMenu *menu = nodeScene()->createSceneMenu(scenePos); + if (auto *groupItem = qgraphicsitem_cast(item)) { + menu = nodeScene()->createGroupMenu(scenePos, groupItem); + break; + } + } + + if (!menu) { + if (!clickedItems.empty()) { + menu = nodeScene()->createStdMenu(scenePos); + } else { + menu = nodeScene()->createSceneMenu(scenePos); + } + } if (menu) { menu->exec(event->globalPos()); } + + return; } void GraphicsView::wheelEvent(QWheelEvent *event) @@ -277,12 +329,17 @@ void GraphicsView::setupScale(double scale) void GraphicsView::onDeleteSelectedObjects() { + if (!nodeScene()) + return; + nodeScene()->undoStack().push(new DeleteCommand(nodeScene())); } void GraphicsView::onDuplicateSelectedObjects() { - qDebug() << "ON DUPLICATE"; + if (!nodeScene()) + return; + QPointF const pastePosition = scenePastePosition(); nodeScene()->undoStack().push(new CopyCommand(nodeScene())); @@ -291,11 +348,17 @@ void GraphicsView::onDuplicateSelectedObjects() void GraphicsView::onCopySelectedObjects() { + if (!nodeScene()) + return; + nodeScene()->undoStack().push(new CopyCommand(nodeScene())); } void GraphicsView::onPasteObjects() { + if (!nodeScene()) + return; + QPointF const pastePosition = scenePastePosition(); nodeScene()->undoStack().push(new PasteCommand(nodeScene(), pastePosition)); } @@ -303,73 +366,6 @@ void GraphicsView::onPasteObjects() void GraphicsView::keyPressEvent(QKeyEvent *event) { switch (event->key()) { - case Qt::Key_F2: { - BasicGraphicsScene *sc = nodeScene(); - - if (sc) { - QList items = sc->selectedItems(); - NodeGraphicsObject *ngo = nullptr; - for (QGraphicsItem *it : items) { - ngo = qgraphicsitem_cast(it); - - if (ngo) - break; - } - - if (ngo) { - bool const labelEditable - = sc->graphModel().nodeData(ngo->nodeId(), NodeRole::LabelEditable).toBool(); - - if (!labelEditable) - break; - - if (!_labelEdit) { - _labelEdit = new QLineEdit(this); - _labelEdit->setMaxLength(32); - - connect(_labelEdit, &QLineEdit::editingFinished, [this]() { - if (_editingNodeId != InvalidNodeId) { - nodeScene()->graphModel().setNodeData(_editingNodeId, - NodeRole::LabelVisible, - true); - nodeScene()->graphModel().setNodeData(_editingNodeId, - NodeRole::Label, - _labelEdit->text()); - } - - _labelEdit->hide(); - _editingNodeId = InvalidNodeId; - }); - } - - _editingNodeId = ngo->nodeId(); - - sc->graphModel().setNodeData(_editingNodeId, NodeRole::LabelVisible, true); - - AbstractNodeGeometry &geom = sc->nodeGeometry(); - QPointF labelPos = geom.labelPosition(_editingNodeId); - QPointF scenePos = ngo->mapToScene(labelPos); - QSize sz = _labelEdit->sizeHint(); - QPoint viewPos = mapFromScene(scenePos); - _labelEdit->move(viewPos.x() - sz.width() / 2, viewPos.y() - sz.height() / 2); - bool visible - = sc->graphModel().nodeData(_editingNodeId, NodeRole::LabelVisible).toBool(); - QString current - = sc->graphModel().nodeData(_editingNodeId, NodeRole::Label).toString(); - - if (!visible && current.isEmpty()) - _labelEdit->clear(); - else - _labelEdit->setText(current); - _labelEdit->resize(sz); - _labelEdit->show(); - _labelEdit->setFocus(); - return; - } - } - } - - break; case Qt::Key_Shift: setDragMode(QGraphicsView::RubberBandDrag); break; @@ -405,6 +401,10 @@ void GraphicsView::mousePressEvent(QMouseEvent *event) void GraphicsView::mouseMoveEvent(QMouseEvent *event) { QGraphicsView::mouseMoveEvent(event); + + if (!scene()) + return; + if (scene()->mouseGrabberItem() == nullptr && event->buttons() == Qt::LeftButton) { // Make sure shift is not being pressed if ((event->modifiers() & Qt::ShiftModifier) == 0) { @@ -477,3 +477,22 @@ QPointF GraphicsView::scenePastePosition() return mapToScene(origin); } + +void GraphicsView::zoomFitAll() +{ + fitInView(scene()->itemsBoundingRect(), Qt::KeepAspectRatio); +} + +void GraphicsView::zoomFitSelected() +{ + if (scene()->selectedItems().count() > 0) { + QRectF unitedBoundingRect{}; + + for (QGraphicsItem *item : scene()->selectedItems()) { + unitedBoundingRect = unitedBoundingRect.united( + item->mapRectToScene(item->boundingRect())); + } + + fitInView(unitedBoundingRect, Qt::KeepAspectRatio); + } +} diff --git a/src/GroupGraphicsObject.cpp b/src/GroupGraphicsObject.cpp new file mode 100644 index 000000000..f9c995fb4 --- /dev/null +++ b/src/GroupGraphicsObject.cpp @@ -0,0 +1,222 @@ +#include "GroupGraphicsObject.hpp" +#include "BasicGraphicsScene.hpp" +#include "NodeConnectionInteraction.hpp" +#include "NodeGraphicsObject.hpp" +#include +#include +#include +#include + +using QtNodes::BasicGraphicsScene; +using QtNodes::ConnectionId; +using QtNodes::DataFlowGraphModel; +using QtNodes::GroupGraphicsObject; +using QtNodes::NodeConnectionInteraction; +using QtNodes::NodeGraphicsObject; +using QtNodes::NodeGroup; + +IconGraphicsItem::IconGraphicsItem(QGraphicsItem *parent) + : QGraphicsPixmapItem(parent) +{} + +IconGraphicsItem::IconGraphicsItem(const QPixmap &pixmap, QGraphicsItem *parent) + : QGraphicsPixmapItem(pixmap, parent) +{ + _scaleFactor = _iconSize / pixmap.size().width(); + setScale(_scaleFactor); +} + +double IconGraphicsItem::scaleFactor() const +{ + return _scaleFactor; +} + +GroupGraphicsObject::GroupGraphicsObject(BasicGraphicsScene &scene, NodeGroup &nodeGroup) + : _scene(scene) + , _group(nodeGroup) + , _possibleChild(nullptr) + , _locked(false) +{ + setRect(0, 0, _defaultWidth, _defaultHeight); + + _lockedGraphicsItem = new IconGraphicsItem(_lockedIcon, this); + _unlockedGraphicsItem = new IconGraphicsItem(_unlockedIcon, this); + + _scene.addItem(this); + + setFlag(QGraphicsItem::ItemIsMovable, true); + setFlag(QGraphicsItem::ItemIsFocusable, true); + setFlag(QGraphicsItem::ItemIsSelectable, true); + setFlag(QGraphicsItem::ItemDoesntPropagateOpacityToChildren, true); + + _currentFillColor = kUnlockedFillColor; + _currentBorderColor = kUnselectedBorderColor; + + _borderPen = QPen(_currentBorderColor, 1.0, Qt::PenStyle::DashLine); + + setZValue(-_groupAreaZValue); + + setAcceptHoverEvents(true); +} + +GroupGraphicsObject::~GroupGraphicsObject() +{ + _scene.removeItem(this); +} + +NodeGroup &GroupGraphicsObject::group() +{ + return _group; +} + +NodeGroup const &GroupGraphicsObject::group() const +{ + return _group; +} + +QRectF GroupGraphicsObject::boundingRect() const +{ + QRectF ret{}; + for (auto &node : _group.childNodes()) { + ret |= node->mapRectToScene(node->boundingRect()); + } + if (_possibleChild) { + ret |= _possibleChild->mapRectToScene(_possibleChild->boundingRect()); + } + return mapRectFromScene(ret.marginsAdded(_margins)); +} + +void GroupGraphicsObject::setFillColor(const QColor &color) +{ + _currentFillColor = color; + update(); +} + +void GroupGraphicsObject::setBorderColor(const QColor &color) +{ + _currentBorderColor = color; + _borderPen.setColor(_currentBorderColor); +} + +void GroupGraphicsObject::moveConnections() +{ + for (auto &node : group().childNodes()) { + node->moveConnections(); + } +} + +void GroupGraphicsObject::moveNodes(const QPointF &offset) +{ + for (auto &node : group().childNodes()) { + auto newPosition = QPointF(node->x() + offset.x(), node->y() + offset.y()); + node->setPos(newPosition); + node->update(); + } +} + +void GroupGraphicsObject::lock(bool locked) +{ + for (auto &node : _group.childNodes()) { + node->lock(locked); + } + _lockedGraphicsItem->setVisible(locked); + _unlockedGraphicsItem->setVisible(!locked); + setFillColor(locked ? kLockedFillColor : kUnlockedFillColor); + _locked = locked; + setZValue(locked ? _groupAreaZValue : -_groupAreaZValue); +} + +bool GroupGraphicsObject::locked() const +{ + return _locked; +} + +void GroupGraphicsObject::positionLockedIcon() +{ + _lockedGraphicsItem->setPos( + boundingRect().topRight() + + QPointF(-(_roundedBorderRadius + IconGraphicsItem::iconSize()), _roundedBorderRadius)); + _unlockedGraphicsItem->setPos( + boundingRect().topRight() + + QPointF(-(_roundedBorderRadius + IconGraphicsItem::iconSize()), _roundedBorderRadius)); +} + +void GroupGraphicsObject::setHovered(bool hovered) +{ + hovered ? setFillColor(locked() ? kLockedHoverColor : kUnlockedHoverColor) + : setFillColor(locked() ? kLockedFillColor : kUnlockedFillColor); + + for (auto &node : _group.childNodes()) { + auto ngo = node->nodeScene()->nodeGraphicsObject(node->nodeId()); + ngo->nodeState().setHovered(hovered); + node->update(); + } + update(); +} + +void GroupGraphicsObject::setPossibleChild(QtNodes::NodeGraphicsObject *possibleChild) +{ + _possibleChild = possibleChild; +} + +void GroupGraphicsObject::unsetPossibleChild() +{ + _possibleChild = nullptr; +} + +std::vector> GroupGraphicsObject::connections() const +{ + return _scene.connectionsWithinGroup(group().id()); +} + +void GroupGraphicsObject::setPosition(const QPointF &position) +{ + QPointF diffPos = position - scenePos(); + moveNodes(diffPos); + moveConnections(); +} + +void GroupGraphicsObject::hoverEnterEvent(QGraphicsSceneHoverEvent *event) +{ + Q_UNUSED(event); + setHovered(true); +} + +void GroupGraphicsObject::hoverLeaveEvent(QGraphicsSceneHoverEvent *event) +{ + Q_UNUSED(event); + setHovered(false); +} + +void GroupGraphicsObject::mouseMoveEvent(QGraphicsSceneMouseEvent *event) +{ + QGraphicsItem::mouseMoveEvent(event); + if (event->lastPos() != event->pos()) { + auto diff = event->pos() - event->lastPos(); + moveNodes(diff); + moveConnections(); + } +} + +void GroupGraphicsObject::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) +{ + QGraphicsItem::mouseDoubleClickEvent(event); + lock(!locked()); +} + +void GroupGraphicsObject::paint(QPainter *painter, + const QStyleOptionGraphicsItem *option, + QWidget *widget) +{ + Q_UNUSED(widget); + prepareGeometryChange(); + setRect(boundingRect()); + positionLockedIcon(); + painter->setClipRect(option->exposedRect); + painter->setBrush(_currentFillColor); + + setBorderColor(isSelected() ? kSelectedBorderColor : kUnselectedBorderColor); + painter->setPen(_borderPen); + + painter->drawRoundedRect(rect(), _roundedBorderRadius, _roundedBorderRadius); +} diff --git a/src/NodeGraphicsObject.cpp b/src/NodeGraphicsObject.cpp index 3eb94b359..61babbb4c 100644 --- a/src/NodeGraphicsObject.cpp +++ b/src/NodeGraphicsObject.cpp @@ -1,5 +1,4 @@ #include "NodeGraphicsObject.hpp" - #include "AbstractGraphModel.hpp" #include "AbstractNodeGeometry.hpp" #include "AbstractNodePainter.hpp" @@ -8,8 +7,10 @@ #include "ConnectionIdUtils.hpp" #include "NodeConnectionInteraction.hpp" #include "NodeDelegateModel.hpp" +#include "NodeGroup.hpp" #include "StyleCollection.hpp" #include "UndoCommands.hpp" +#include #include #include @@ -22,6 +23,10 @@ NodeGraphicsObject::NodeGraphicsObject(BasicGraphicsScene &scene, NodeId nodeId) : _nodeId(nodeId) , _graphModel(scene.graphModel()) , _nodeState(*this) + , _locked(false) + , _draggingIntoGroup(false) + , _possibleGroup(nullptr) + , _originalGroupSize() , _proxyWidget(nullptr) { scene.addItem(this); @@ -60,12 +65,10 @@ NodeGraphicsObject::NodeGraphicsObject(BasicGraphicsScene &scene, NodeId nodeId) setPos(pos); - connect(&_graphModel, &AbstractGraphModel::nodeFlagsUpdated, [this](NodeId const nodeId) { + connect(&_graphModel, &AbstractGraphModel::nodeFlagsUpdated, this, [this](NodeId const nodeId) { if (_nodeId == nodeId) setLockedState(); }); - - QVariant var = _graphModel.nodeData(_nodeId, NodeRole::ProcessingStatus); } AbstractGraphModel &NodeGraphicsObject::graphModel() const @@ -141,6 +144,11 @@ void NodeGraphicsObject::setGeometryChanged() prepareGeometryChange(); } +void NodeGraphicsObject::setNodeGroup(std::shared_ptr group) +{ + _nodeGroup = group; +} + void NodeGraphicsObject::moveConnections() const { auto const &connected = _graphModel.allConnectionIds(_nodeId); @@ -188,7 +196,8 @@ QVariant NodeGraphicsObject::itemChange(GraphicsItemChange change, const QVarian void NodeGraphicsObject::mousePressEvent(QGraphicsSceneMouseEvent *event) { - if (graphModel().nodeFlags(_nodeId) & NodeFlag::Locked) { + if (_locked) { + nodeScene()->clearSelection(); return; } @@ -292,11 +301,50 @@ void NodeGraphicsObject::mouseMoveEvent(QGraphicsSceneMouseEvent *event) event->accept(); } } else { - auto diff = event->pos() - event->lastPos(); - - nodeScene()->undoStack().push(new MoveNodeCommand(nodeScene(), diff)); - - event->accept(); + QGraphicsObject::mouseMoveEvent(event); + + if (event->lastPos() != event->pos()) { + auto diff = event->pos() - event->lastPos(); + if (nodeScene()->groupingEnabled()) { + if (auto nodeGroup = _nodeGroup.lock(); nodeGroup) { + nodeGroup->groupGraphicsObject().moveConnections(); + if (nodeGroup->groupGraphicsObject().locked()) { + nodeGroup->groupGraphicsObject().moveNodes(diff); + } + } else { + moveConnections(); + // if it intersects with a group, expand group + QList overlapItems = collidingItems(); + for (auto &item : overlapItems) { + auto ggo = qgraphicsitem_cast(item); + if (ggo != nullptr) { + if (!ggo->locked()) { + if (!_draggingIntoGroup) { + _draggingIntoGroup = true; + _possibleGroup = ggo; + _originalGroupSize = _possibleGroup->mapRectToScene(ggo->rect()); + _possibleGroup->setPossibleChild(this); + break; + } else { + if (ggo == _possibleGroup) { + if (!boundingRect().intersects( + mapRectFromScene(_originalGroupSize))) { + _draggingIntoGroup = false; + _originalGroupSize = QRectF(); + _possibleGroup->unsetPossibleChild(); + _possibleGroup = nullptr; + } + } + } + } + } + } + } + } else { + moveConnections(); + } + } + event->ignore(); } QRectF r = nodeScene()->sceneRect(); @@ -315,6 +363,15 @@ void NodeGraphicsObject::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) // position connections precisely after fast node move moveConnections(); + if (nodeScene()->groupingEnabled() && _draggingIntoGroup && _possibleGroup + && _nodeGroup.expired()) { + nodeScene()->addNodeToGroup(_nodeId, _possibleGroup->group().id()); + _possibleGroup->unsetPossibleChild(); + _draggingIntoGroup = false; + _originalGroupSize = QRectF(); + _possibleGroup = nullptr; + } + nodeScene()->nodeClicked(_nodeId); } @@ -324,6 +381,11 @@ void NodeGraphicsObject::hoverEnterEvent(QGraphicsSceneHoverEvent *event) QList overlapItems = collidingItems(); for (QGraphicsItem *item : overlapItems) { + if (auto group = qgraphicsitem_cast(item)) { + Q_UNUSED(group); + continue; + } + if (item->zValue() > 0.0) { item->setZValue(0.0); } @@ -383,4 +445,26 @@ void NodeGraphicsObject::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) Q_EMIT nodeScene()->nodeContextMenu(_nodeId, mapToScene(event->pos())); } +void NodeGraphicsObject::lock(bool locked) +{ + _locked = locked; + + setFlag(QGraphicsItem::ItemIsFocusable, !locked); + setFlag(QGraphicsItem::ItemIsSelectable, !locked); +} + +QJsonObject NodeGraphicsObject::save() const +{ + QJsonObject nodeJson = _graphModel.saveNode(_nodeId); + if (nodeJson.isEmpty()) { + nodeJson["id"] = QString::number(_nodeId); + QJsonObject obj; + obj["x"] = pos().x(); + obj["y"] = pos().y(); + nodeJson["position"] = obj; + } + + return nodeJson; +} + } // namespace QtNodes diff --git a/src/NodeGroup.cpp b/src/NodeGroup.cpp new file mode 100644 index 000000000..901fb5e25 --- /dev/null +++ b/src/NodeGroup.cpp @@ -0,0 +1,126 @@ +#include "NodeGroup.hpp" +#include "ConnectionIdUtils.hpp" +#include "NodeConnectionInteraction.hpp" +#include +#include + +#include + +using QtNodes::DataFlowGraphModel; +using QtNodes::GroupGraphicsObject; +using QtNodes::NodeConnectionInteraction; +using QtNodes::NodeGraphicsObject; +using QtNodes::NodeGroup; +using QtNodes::NodeId; + +int NodeGroup::_groupCount = 0; + +NodeGroup::NodeGroup(std::vector nodes, + GroupId groupId, + QString name, + QObject *parent) + : QObject(parent) + , _name(std::move(name)) + , _id(groupId) + , _childNodes(std::move(nodes)) + , _groupGraphicsObject(nullptr) +{ + _groupCount++; +} + +QByteArray NodeGroup::saveToFile() const +{ + QJsonObject groupJson; + + groupJson["name"] = _name; + groupJson["id"] = static_cast(_id); + + QJsonArray nodesJson; + for (auto const &node : _childNodes) { + nodesJson.append(node->save()); + } + groupJson["nodes"] = nodesJson; + + QJsonArray connectionsJson; + auto groupConnections = _groupGraphicsObject->connections(); + for (auto const &connection : groupConnections) { + connectionsJson.append(toJson(*connection)); + } + groupJson["connections"] = connectionsJson; + + QJsonDocument groupDocument(groupJson); + + return groupDocument.toJson(); +} + +QtNodes::GroupId NodeGroup::id() const +{ + return _id; +} + +GroupGraphicsObject &NodeGroup::groupGraphicsObject() +{ + return *_groupGraphicsObject; +} + +GroupGraphicsObject const &NodeGroup::groupGraphicsObject() const +{ + return *_groupGraphicsObject; +} + +std::vector &NodeGroup::childNodes() +{ + return _childNodes; +} + +std::vector NodeGroup::nodeIDs() const +{ + std::vector ret{}; + ret.reserve(_childNodes.size()); + + for (auto const &node : _childNodes) { + ret.push_back(node->nodeId()); + } + + return ret; +} + +QString const &NodeGroup::name() const +{ + return _name; +} + +void NodeGroup::setGraphicsObject(std::unique_ptr &&graphics_object) +{ + _groupGraphicsObject = std::move(graphics_object); + _groupGraphicsObject->lock(true); +} + +bool NodeGroup::empty() const +{ + return _childNodes.empty(); +} + +int NodeGroup::groupCount() +{ + return _groupCount; +} + +void NodeGroup::addNode(NodeGraphicsObject *node) +{ + _childNodes.push_back(node); + if (_groupGraphicsObject && _groupGraphicsObject->locked()) { + node->lock(true); + } +} + +void NodeGroup::removeNode(NodeGraphicsObject *node) +{ + auto nodeIt = std::find(_childNodes.begin(), _childNodes.end(), node); + + if (nodeIt != _childNodes.end()) { + (*nodeIt)->unsetNodeGroup(); + _childNodes.erase(nodeIt); + groupGraphicsObject().positionLockedIcon(); + } +} diff --git a/src/NodeStyle.cpp b/src/NodeStyle.cpp index 4b469ea1c..0cfacf51b 100644 --- a/src/NodeStyle.cpp +++ b/src/NodeStyle.cpp @@ -21,6 +21,14 @@ NodeStyle::NodeStyle() // order fiasco: https://isocpp.org/wiki/faq/ctors#static-init-order initResources(); + // Initialize status icons after resources are loaded + statusUpdated = QIcon(":/status_icons/updated.svg"); + statusProcessing = QIcon(":/status_icons/processing.svg"); + statusPending = QIcon(":/status_icons/pending.svg"); + statusInvalid = QIcon(":/status_icons/failed.svg"); + statusEmpty = QIcon(":/status_icons/empty.svg"); + statusPartial = QIcon(":/status_icons/partial.svg"); + // This configuration is stored inside the compiled unit and is loaded statically loadJsonFile(":DefaultStyle.json"); } diff --git a/src/UndoCommands.cpp b/src/UndoCommands.cpp index c98289cf6..265d7e17c 100644 --- a/src/UndoCommands.cpp +++ b/src/UndoCommands.cpp @@ -4,8 +4,10 @@ #include "ConnectionGraphicsObject.hpp" #include "ConnectionIdUtils.hpp" #include "Definitions.hpp" +#include "GroupGraphicsObject.hpp" #include "NodeGraphicsObject.hpp" +#include #include #include #include @@ -13,7 +15,6 @@ #include #include - namespace QtNodes { static QJsonObject serializeSelectedItems(BasicGraphicsScene *scene) @@ -25,16 +26,58 @@ static QJsonObject serializeSelectedItems(BasicGraphicsScene *scene) std::unordered_set selectedNodes; QJsonArray nodesJsonArray; + QJsonArray groupsJsonArray; + QJsonArray connJsonArray; + + auto appendNode = [&](NodeGraphicsObject *node) { + if (!node) + return; + + auto const inserted = selectedNodes.insert(node->nodeId()); + if (inserted.second) { + nodesJsonArray.append(graphModel.saveNode(node->nodeId())); + } + }; for (QGraphicsItem *item : scene->selectedItems()) { - if (auto n = qgraphicsitem_cast(item)) { - nodesJsonArray.append(graphModel.saveNode(n->nodeId())); + if (auto group = qgraphicsitem_cast(item)) { + for (auto *node : group->group().childNodes()) { + appendNode(node); - selectedNodes.insert(n->nodeId()); + for (auto const &connectionId : graphModel.allConnectionIds(node->nodeId())) { + connJsonArray.append(toJson(connectionId)); + } + } } } - QJsonArray connJsonArray; + for (QGraphicsItem *item : scene->selectedItems()) { + if (auto ngo = qgraphicsitem_cast(item)) { + appendNode(ngo); + + for (auto const &connectionId : graphModel.allConnectionIds(ngo->nodeId())) { + connJsonArray.append(toJson(connectionId)); + } + } + } + + for (QGraphicsItem *item : scene->selectedItems()) { + if (auto groupGo = qgraphicsitem_cast(item)) { + auto &group = groupGo->group(); + + QJsonObject groupJson; + groupJson["id"] = static_cast(group.id()); + groupJson["name"] = group.name(); + + QJsonArray nodeIdsJson; + for (NodeGraphicsObject *node : group.childNodes()) { + nodeIdsJson.append(static_cast(node->nodeId())); + } + + groupJson["nodes"] = nodeIdsJson; + groupsJsonArray.append(groupJson); + } + } for (QGraphicsItem *item : scene->selectedItems()) { if (auto c = qgraphicsitem_cast(item)) { @@ -46,6 +89,7 @@ static QJsonObject serializeSelectedItems(BasicGraphicsScene *scene) } } + serializedScene["groups"] = groupsJsonArray; serializedScene["nodes"] = nodesJsonArray; serializedScene["connections"] = connJsonArray; @@ -80,6 +124,28 @@ static void insertSerializedItems(QJsonObject const &json, BasicGraphicsScene *s scene->connectionGraphicsObject(connId)->setSelected(true); } + + if (json.contains("groups")) { + QJsonArray groupsJsonArray = json["groups"].toArray(); + + for (const QJsonValue &groupValue : groupsJsonArray) { + QJsonObject groupJson = groupValue.toObject(); + + QString name = QString("Group %1").arg(NodeGroup::groupCount()); + QJsonArray nodeIdsJson = groupJson["nodes"].toArray(); + + std::vector groupNodes; + + for (const QJsonValue &idVal : nodeIdsJson) { + NodeId nodeId = static_cast(idVal.toInt()); + if (auto *ngo = scene->nodeGraphicsObject(nodeId)) { + groupNodes.push_back(ngo); + } + } + + scene->createGroup(groupNodes, name); + } + } } static void deleteSerializedItems(QJsonObject &sceneJson, AbstractGraphModel &graphModel) @@ -175,23 +241,65 @@ DeleteCommand::DeleteCommand(BasicGraphicsScene *scene) QJsonArray nodesJsonArray; // Delete the nodes; this will delete many of the connections. // Selected connections were already deleted prior to this loop, + + std::unordered_set processedNodes; + + auto appendNode = [&](NodeGraphicsObject *node) { + if (!node) + return; + + auto const inserted = processedNodes.insert(node->nodeId()); + if (!inserted.second) + return; + + for (auto const &cid : graphModel.allConnectionIds(node->nodeId())) { + connJsonArray.append(toJson(cid)); + } + + nodesJsonArray.append(graphModel.saveNode(node->nodeId())); + }; + + QJsonArray groupsJsonArray; + for (QGraphicsItem *item : _scene->selectedItems()) { - if (auto n = qgraphicsitem_cast(item)) { - // saving connections attached to the selected nodes - for (auto const &cid : graphModel.allConnectionIds(n->nodeId())) { - connJsonArray.append(toJson(cid)); + if (auto groupGo = qgraphicsitem_cast(item)) { + auto &groupData = groupGo->group(); + + QJsonArray groupNodeIdsJsonArray; + for (NodeGraphicsObject *node : groupData.childNodes()) { + appendNode(node); + groupNodeIdsJsonArray.append(static_cast(node->nodeId())); + } + + QJsonObject groupJson; + groupJson["id"] = static_cast(groupData.id()); + groupJson["name"] = groupData.name(); + groupJson["nodes"] = groupNodeIdsJsonArray; + groupsJsonArray.append(groupJson); + } + } + + for (QGraphicsItem *item : _scene->selectedItems()) { + if (auto group = qgraphicsitem_cast(item)) { + for (auto *node : group->group().childNodes()) { + appendNode(node); } + } + } - nodesJsonArray.append(graphModel.saveNode(n->nodeId())); + for (QGraphicsItem *item : _scene->selectedItems()) { + if (auto n = qgraphicsitem_cast(item)) { + appendNode(n); } } // If nothing is deleted, cancel this operation - if (connJsonArray.isEmpty() && nodesJsonArray.isEmpty()) + if (connJsonArray.isEmpty() && nodesJsonArray.isEmpty() && groupsJsonArray.isEmpty()) setObsolete(true); _sceneJson["nodes"] = nodesJsonArray; _sceneJson["connections"] = connJsonArray; + _sceneJson["groups"] = groupsJsonArray; } void DeleteCommand::undo() @@ -365,6 +473,28 @@ QJsonObject PasteCommand::makeNewNodeIdsInScene(QJsonObject const &sceneJson) newSceneJson["nodes"] = newNodesJsonArray; newSceneJson["connections"] = newConnJsonArray; + if (sceneJson.contains("groups")) { + QJsonArray groupsJsonArray = sceneJson["groups"].toArray(); + QJsonArray newGroupsJsonArray; + + for (const QJsonValue &groupVal : groupsJsonArray) { + QJsonObject groupJson = groupVal.toObject(); + QJsonArray nodeIdsJson = groupJson["nodes"].toArray(); + + QJsonArray newNodeIdsJson; + for (const QJsonValue &idVal : nodeIdsJson) { + NodeId oldId = static_cast(idVal.toInt()); + NodeId newId = mapNodeIds[oldId]; + newNodeIdsJson.append(static_cast(newId)); + } + + groupJson["nodes"] = newNodeIdsJson; + newGroupsJsonArray.append(groupJson); + } + + newSceneJson["groups"] = newGroupsJsonArray; + } + return newSceneJson; } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 681251330..6432f73b3 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -14,8 +14,14 @@ add_executable(test_nodes src/TestSerialization.cpp src/TestUndoCommands.cpp src/TestBasicGraphicsScene.cpp + src/TestNodeGroup.cpp src/TestUIInteraction.cpp src/TestDataFlow.cpp + src/TestNodeValidation.cpp + src/TestCustomPainters.cpp + src/TestCopyPaste.cpp + src/TestZoomFeatures.cpp + src/TestLoopDetection.cpp include/ApplicationSetup.hpp include/TestGraphModel.hpp include/UITestHelper.hpp diff --git a/test/include/TestGraphModel.hpp b/test/include/TestGraphModel.hpp index 61590e48b..ee8918aa4 100644 --- a/test/include/TestGraphModel.hpp +++ b/test/include/TestGraphModel.hpp @@ -241,10 +241,41 @@ class TestGraphModel : public AbstractGraphModel posObj["y"] = pos.y(); result["position"] = posObj; } + auto typeIt = data.find(NodeRole::Type); + if (typeIt != data.end()) { + result["type"] = typeIt->second.toString(); + } } return result; } + void loadNode(QJsonObject const &nodeJson) override + { + NodeId id = static_cast(nodeJson["id"].toInt()); + + _nodeIds.insert(id); + + if (id >= _nextNodeId) { + _nextNodeId = id + 1; + } + + QJsonObject posObj = nodeJson["position"].toObject(); + QPointF pos(posObj["x"].toDouble(), posObj["y"].toDouble()); + _nodeData[id][NodeRole::Position] = pos; + + if (nodeJson.contains("type")) { + _nodeData[id][NodeRole::Type] = nodeJson["type"].toString(); + } else { + _nodeData[id][NodeRole::Type] = QString("TestNode"); + } + + _nodeData[id][NodeRole::Caption] = QString("Node %1").arg(id); + _nodeData[id][NodeRole::InPortCount] = 1u; + _nodeData[id][NodeRole::OutPortCount] = 1u; + + Q_EMIT nodeCreated(id); + } + private: NodeId _nextNodeId = 1; std::unordered_set _nodeIds; diff --git a/test/src/TestCopyPaste.cpp b/test/src/TestCopyPaste.cpp new file mode 100644 index 000000000..808b4cc14 --- /dev/null +++ b/test/src/TestCopyPaste.cpp @@ -0,0 +1,156 @@ +#include "ApplicationSetup.hpp" +#include "TestGraphModel.hpp" + +#include + +#include +#include +#include + +#include +#include +#include + +using QtNodes::BasicGraphicsScene; +using QtNodes::ConnectionId; +using QtNodes::GraphicsView; +using QtNodes::NodeId; +using QtNodes::NodeRole; + +TEST_CASE("Copy/Paste basic functionality", "[copypaste]") +{ + auto app = applicationSetup(); + + auto model = std::make_shared(); + BasicGraphicsScene scene(*model); + GraphicsView view(&scene); + + view.resize(800, 600); + view.show(); + REQUIRE(QTest::qWaitForWindowExposed(&view)); + + SECTION("Copy single node") + { + // Create a node + NodeId nodeId = model->addNode("TestNode"); + model->setNodeData(nodeId, NodeRole::Position, QPointF(100, 100)); + + QCoreApplication::processEvents(); + + // Select the node + auto *nodeGraphics = scene.nodeGraphicsObject(nodeId); + REQUIRE(nodeGraphics != nullptr); + nodeGraphics->setSelected(true); + + QCoreApplication::processEvents(); + + // Trigger copy action + view.onCopySelectedObjects(); + + QCoreApplication::processEvents(); + + // Clipboard should contain data + QClipboard *clipboard = QApplication::clipboard(); + QString clipboardText = clipboard->text(); + // We check that clipboard has some content (exact format is implementation detail) + CHECK(!clipboardText.isEmpty()); + } + + SECTION("Copy and paste creates new node") + { + // Create a node + NodeId nodeId = model->addNode("TestNode"); + model->setNodeData(nodeId, NodeRole::Position, QPointF(100, 100)); + + QCoreApplication::processEvents(); + + // Select the node + auto *nodeGraphics = scene.nodeGraphicsObject(nodeId); + REQUIRE(nodeGraphics != nullptr); + nodeGraphics->setSelected(true); + + size_t initialNodeCount = model->allNodeIds().size(); + CHECK(initialNodeCount == 1); + + // Copy + view.onCopySelectedObjects(); + QCoreApplication::processEvents(); + + // Paste + view.onPasteObjects(); + QCoreApplication::processEvents(); + + // Should have a new node + CHECK(model->allNodeIds().size() >= initialNodeCount); + } + + SECTION("Duplicate creates new node") + { + // Create a node + NodeId nodeId = model->addNode("TestNode"); + model->setNodeData(nodeId, NodeRole::Position, QPointF(100, 100)); + + QCoreApplication::processEvents(); + + // Select the node + auto *nodeGraphics = scene.nodeGraphicsObject(nodeId); + REQUIRE(nodeGraphics != nullptr); + nodeGraphics->setSelected(true); + + size_t initialNodeCount = model->allNodeIds().size(); + + // Duplicate + view.onDuplicateSelectedObjects(); + QCoreApplication::processEvents(); + + // Should have a new node + CHECK(model->allNodeIds().size() > initialNodeCount); + } +} + +TEST_CASE("Copy/Paste with connections", "[copypaste]") +{ + auto app = applicationSetup(); + + auto model = std::make_shared(); + BasicGraphicsScene scene(*model); + GraphicsView view(&scene); + + view.resize(800, 600); + view.show(); + REQUIRE(QTest::qWaitForWindowExposed(&view)); + + SECTION("Copy multiple connected nodes") + { + // Create two connected nodes + NodeId node1 = model->addNode("Node1"); + model->setNodeData(node1, NodeRole::Position, QPointF(100, 100)); + + NodeId node2 = model->addNode("Node2"); + model->setNodeData(node2, NodeRole::Position, QPointF(300, 100)); + + model->addConnection(ConnectionId{node1, 0, node2, 0}); + + QCoreApplication::processEvents(); + + // Select both nodes + auto *nodeGraphics1 = scene.nodeGraphicsObject(node1); + auto *nodeGraphics2 = scene.nodeGraphicsObject(node2); + REQUIRE(nodeGraphics1 != nullptr); + REQUIRE(nodeGraphics2 != nullptr); + + nodeGraphics1->setSelected(true); + nodeGraphics2->setSelected(true); + + QCoreApplication::processEvents(); + + size_t initialNodeCount = model->allNodeIds().size(); + + // Duplicate + view.onDuplicateSelectedObjects(); + QCoreApplication::processEvents(); + + // Should have new nodes + CHECK(model->allNodeIds().size() > initialNodeCount); + } +} diff --git a/test/src/TestCustomPainters.cpp b/test/src/TestCustomPainters.cpp new file mode 100644 index 000000000..26d0402b3 --- /dev/null +++ b/test/src/TestCustomPainters.cpp @@ -0,0 +1,180 @@ +#include "ApplicationSetup.hpp" +#include "TestGraphModel.hpp" +#include "UITestHelper.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +using QtNodes::AbstractConnectionPainter; +using QtNodes::AbstractNodePainter; +using QtNodes::BasicGraphicsScene; +using QtNodes::ConnectionGraphicsObject; +using QtNodes::ConnectionId; +using QtNodes::GraphicsView; +using QtNodes::NodeGraphicsObject; +using QtNodes::NodeId; +using QtNodes::NodeRole; + +/// Custom node painter for testing +class TestNodePainter : public AbstractNodePainter +{ +public: + mutable int paintCallCount = 0; + mutable NodeId lastPaintedNodeId = 0; + + void paint(QPainter *painter, NodeGraphicsObject &ngo) const override + { + paintCallCount++; + lastPaintedNodeId = ngo.nodeId(); + + // Simple paint implementation + painter->setBrush(Qt::blue); + painter->drawRect(0, 0, 100, 50); + } +}; + +/// Custom connection painter for testing +class TestConnectionPainter : public AbstractConnectionPainter +{ +public: + mutable int paintCallCount = 0; + + void paint(QPainter *painter, ConnectionGraphicsObject const &cgo) const override + { + paintCallCount++; + + QPen pen(Qt::red, 2); + painter->setPen(pen); + painter->drawLine(cgo.endPoint(QtNodes::PortType::Out), cgo.endPoint(QtNodes::PortType::In)); + } + + QPainterPath getPainterStroke(ConnectionGraphicsObject const &cgo) const override + { + QPainterPath path; + path.moveTo(cgo.endPoint(QtNodes::PortType::Out)); + path.lineTo(cgo.endPoint(QtNodes::PortType::In)); + + QPainterPathStroker stroker; + stroker.setWidth(10.0); + return stroker.createStroke(path); + } +}; + +TEST_CASE("Custom painters registration", "[painters]") +{ + auto app = applicationSetup(); + + TestGraphModel model; + BasicGraphicsScene scene(model); + + SECTION("Scene has default painters initially") + { + // Scene should have valid painters + AbstractNodePainter &nodePainter = scene.nodePainter(); + AbstractConnectionPainter &connPainter = scene.connectionPainter(); + + // Basic check that painters exist (won't crash) + CHECK(&nodePainter != nullptr); + CHECK(&connPainter != nullptr); + } + + SECTION("Custom node painter can be registered") + { + auto customPainter = std::make_unique(); + TestNodePainter *painterPtr = customPainter.get(); + + scene.setNodePainter(std::move(customPainter)); + + // Verify the painter was set + AbstractNodePainter ¤tPainter = scene.nodePainter(); + CHECK(¤tPainter == painterPtr); + } + + SECTION("Custom connection painter can be registered") + { + auto customPainter = std::make_unique(); + TestConnectionPainter *painterPtr = customPainter.get(); + + scene.setConnectionPainter(std::move(customPainter)); + + // Verify the painter was set + AbstractConnectionPainter ¤tPainter = scene.connectionPainter(); + CHECK(¤tPainter == painterPtr); + } + + SECTION("Painter replacement") + { + auto painter1 = std::make_unique(); + auto painter2 = std::make_unique(); + TestNodePainter *ptr2 = painter2.get(); + + scene.setNodePainter(std::move(painter1)); + scene.setNodePainter(std::move(painter2)); + + // Second painter should be active + AbstractNodePainter ¤tPainter = scene.nodePainter(); + CHECK(¤tPainter == ptr2); + } +} + +TEST_CASE("Custom painter with scene operations", "[painters]") +{ + auto app = applicationSetup(); + + auto model = std::make_shared(); + BasicGraphicsScene scene(*model); + + auto customNodePainter = std::make_unique(); + TestNodePainter *nodePainterPtr = customNodePainter.get(); + scene.setNodePainter(std::move(customNodePainter)); + + SECTION("Custom painter persists after node creation and view operations") + { + GraphicsView view(&scene); + view.resize(800, 600); + view.show(); + REQUIRE(QTest::qWaitForWindowExposed(&view)); + + NodeId nodeId = model->addNode("TestNode"); + model->setNodeData(nodeId, NodeRole::Position, QPointF(100, 100)); + + QCoreApplication::processEvents(); + + // Verify the node graphics object exists + auto *ngo = scene.nodeGraphicsObject(nodeId); + REQUIRE(ngo != nullptr); + + // Verify the custom painter is still set on the scene after all operations + CHECK(&scene.nodePainter() == nodePainterPtr); + } + + SECTION("Custom painter persists through multiple node lifecycle events") + { + // Create nodes + NodeId node1 = model->addNode("TestNode1"); + NodeId node2 = model->addNode("TestNode2"); + model->setNodeData(node1, NodeRole::Position, QPointF(0, 0)); + model->setNodeData(node2, NodeRole::Position, QPointF(200, 0)); + + QCoreApplication::processEvents(); + + // Delete one node + model->deleteNode(node1); + + QCoreApplication::processEvents(); + + // Custom painter should still be set + CHECK(&scene.nodePainter() == nodePainterPtr); + } +} diff --git a/test/src/TestLoopDetection.cpp b/test/src/TestLoopDetection.cpp new file mode 100644 index 000000000..7e5255034 --- /dev/null +++ b/test/src/TestLoopDetection.cpp @@ -0,0 +1,168 @@ +#include "ApplicationSetup.hpp" +#include "TestGraphModel.hpp" +#include "TestDataFlowNodes.hpp" + +#include + +#include +#include + +using QtNodes::ConnectionId; +using QtNodes::DataFlowGraphModel; +using QtNodes::NodeDelegateModelRegistry; +using QtNodes::NodeId; + +/// Test model that allows loops (default behavior) +class LoopEnabledModel : public TestGraphModel +{ +public: + bool loopsEnabled() const override { return true; } +}; + +/// Test model that disables loops +class LoopDisabledModel : public TestGraphModel +{ +public: + bool loopsEnabled() const override { return false; } +}; + +TEST_CASE("Loop detection configuration", "[loops]") +{ + SECTION("Default AbstractGraphModel allows loops") + { + TestGraphModel model; + CHECK(model.loopsEnabled() == true); + } + + SECTION("DataFlowGraphModel disables loops by default") + { + auto app = applicationSetup(); + auto registry = std::make_shared(); + registry->registerModel("Sources"); + + DataFlowGraphModel model(registry); + CHECK(model.loopsEnabled() == false); + } + + SECTION("Custom model can enable loops") + { + LoopEnabledModel model; + CHECK(model.loopsEnabled() == true); + } + + SECTION("Custom model can disable loops") + { + LoopDisabledModel model; + CHECK(model.loopsEnabled() == false); + } +} + +TEST_CASE("Loop detection in DataFlowGraphModel", "[loops]") +{ + auto app = applicationSetup(); + auto registry = std::make_shared(); + registry->registerModel("Sources"); + registry->registerModel("Sinks"); + + DataFlowGraphModel model(registry); + + SECTION("Direct self-loop is not possible") + { + NodeId node1 = model.addNode("TestSourceNode"); + + // Try to connect node to itself + ConnectionId selfLoop{node1, 0, node1, 0}; + CHECK_FALSE(model.connectionPossible(selfLoop)); + } + + SECTION("Simple A->B connection is allowed") + { + NodeId node1 = model.addNode("TestSourceNode"); + NodeId node2 = model.addNode("TestDisplayNode"); + + ConnectionId conn{node1, 0, node2, 0}; + CHECK(model.connectionPossible(conn)); + + model.addConnection(conn); + CHECK(model.connectionExists(conn)); + } + + SECTION("Indirect loop A->B->A is prevented") + { + // Use TestDisplayNode which has both input and output ports + NodeId node1 = model.addNode("TestDisplayNode"); + NodeId node2 = model.addNode("TestDisplayNode"); + + // Create A->B connection + ConnectionId conn1{node1, 0, node2, 0}; + CHECK(model.connectionPossible(conn1)); + model.addConnection(conn1); + + // Try to create B->A connection (would form a loop) + ConnectionId conn2{node2, 0, node1, 0}; + CHECK_FALSE(model.connectionPossible(conn2)); + } + + SECTION("Three node loop A->B->C->A is prevented") + { + // Use TestDisplayNode which has both input and output ports + NodeId node1 = model.addNode("TestDisplayNode"); + NodeId node2 = model.addNode("TestDisplayNode"); + NodeId node3 = model.addNode("TestDisplayNode"); + + // Create A->B + ConnectionId conn1{node1, 0, node2, 0}; + model.addConnection(conn1); + CHECK(model.connectionExists(conn1)); + + // Create B->C + ConnectionId conn2{node2, 0, node3, 0}; + model.addConnection(conn2); + CHECK(model.connectionExists(conn2)); + + // Try to create C->A (would form a loop) + ConnectionId conn3{node3, 0, node1, 0}; + CHECK_FALSE(model.connectionPossible(conn3)); + } +} + +TEST_CASE("Loop-enabled model allows cycles", "[loops]") +{ + LoopEnabledModel model; + + NodeId node1 = model.addNode("Node1"); + NodeId node2 = model.addNode("Node2"); + + // Create A->B connection + ConnectionId conn1{node1, 0, node2, 0}; + model.addConnection(conn1); + CHECK(model.connectionExists(conn1)); + + SECTION("B->A connection is allowed when loops enabled") + { + ConnectionId conn2{node2, 0, node1, 0}; + // With loops enabled, this should be possible + CHECK(model.connectionPossible(conn2)); + } +} + +TEST_CASE("Loop-disabled model prevents cycles", "[loops]") +{ + LoopDisabledModel model; + + NodeId node1 = model.addNode("Node1"); + NodeId node2 = model.addNode("Node2"); + + // Note: TestGraphModel's connectionPossible doesn't check for loops, + // it only checks if nodes exist and aren't self-connecting. + // The loop detection is in DataFlowGraphModel's implementation. + // This test documents the expected behavior when properly implemented. + + ConnectionId conn1{node1, 0, node2, 0}; + model.addConnection(conn1); + + SECTION("Loop-disabled model configuration") + { + CHECK(model.loopsEnabled() == false); + } +} diff --git a/test/src/TestNodeGroup.cpp b/test/src/TestNodeGroup.cpp new file mode 100644 index 000000000..6599656eb --- /dev/null +++ b/test/src/TestNodeGroup.cpp @@ -0,0 +1,372 @@ +#include "ApplicationSetup.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +using QtNodes::BasicGraphicsScene; +using QtNodes::ConnectionId; +using QtNodes::DataFlowGraphModel; +using QtNodes::GroupId; +using QtNodes::NodeDelegateModel; +using QtNodes::NodeDelegateModelRegistry; +using QtNodes::NodeGraphicsObject; +using QtNodes::NodeGroup; +using QtNodes::NodeId; +using QtNodes::NodeRole; +using QtNodes::PortIndex; +using QtNodes::PortType; + +namespace { +class DummyNodeModel : public NodeDelegateModel +{ +public: + QString caption() const override { return QStringLiteral("Dummy Node"); } + QString name() const override { return QStringLiteral("DummyNode"); } + + unsigned int nPorts(PortType portType) const override + { + Q_UNUSED(portType); + return 1U; + } + + QtNodes::NodeDataType dataType(PortType, PortIndex) const override { return {}; } + + std::shared_ptr outData(PortIndex const) override { return nullptr; } + + void setInData(std::shared_ptr, PortIndex const) override {} + + QWidget *embeddedWidget() override { return nullptr; } +}; + +std::shared_ptr createDummyRegistry() +{ + auto registry = std::make_shared(); + registry->registerModel(QStringLiteral("Test")); + return registry; +} + +NodeId createNode(DataFlowGraphModel &model, BasicGraphicsScene &scene) +{ + NodeId nodeId = model.addNode(QStringLiteral("DummyNode")); + REQUIRE(nodeId != QtNodes::InvalidNodeId); + + QCoreApplication::processEvents(); + + auto *nodeObject = scene.nodeGraphicsObject(nodeId); + REQUIRE(nodeObject != nullptr); + + return nodeId; +} + +std::set toNodeIdSet(std::vector const &ids) +{ + return {ids.begin(), ids.end()}; +} + +} // namespace + +TEST_CASE("Node group creation", "[node-group]") +{ + auto app = applicationSetup(); + + auto registry = createDummyRegistry(); + DataFlowGraphModel model(registry); + BasicGraphicsScene scene(model); + scene.setGroupingEnabled(true); + + SECTION("Creating a group from a single node") + { + NodeId nodeId = createNode(model, scene); + auto *nodeObject = scene.nodeGraphicsObject(nodeId); + REQUIRE(nodeObject != nullptr); + + std::vector nodes{nodeObject}; + auto groupWeak = scene.createGroup(nodes, QStringLiteral("SingleGroup")); + auto group = groupWeak.lock(); + + REQUIRE(group); + CHECK(scene.groups().size() == 1); + REQUIRE(group->childNodes().size() == 1); + CHECK(group->childNodes().front()->nodeId() == nodeId); + + auto groupIds = group->nodeIDs(); + REQUIRE(groupIds.size() == 1); + CHECK(groupIds.front() == nodeId); + + auto nodeGroup = nodeObject->nodeGroup().lock(); + REQUIRE(nodeGroup); + CHECK(nodeGroup->id() == group->id()); + } + + SECTION("Creating multiple groups and verifying membership") + { + constexpr std::size_t nodesPerGroup = 3; + constexpr std::size_t groupCount = 2; + + std::vector> expectedNodeIds(groupCount); + std::vector groupIds; + + for (std::size_t groupIndex = 0; groupIndex < groupCount; ++groupIndex) { + std::vector nodeObjects; + nodeObjects.reserve(nodesPerGroup); + expectedNodeIds[groupIndex].reserve(nodesPerGroup); + + for (std::size_t i = 0; i < nodesPerGroup; ++i) { + NodeId nodeId = createNode(model, scene); + expectedNodeIds[groupIndex].push_back(nodeId); + nodeObjects.push_back(scene.nodeGraphicsObject(nodeId)); + } + + auto groupWeak = scene.createGroup(nodeObjects, + QStringLiteral("Group%1").arg(groupIndex)); + auto group = groupWeak.lock(); + REQUIRE(group); + groupIds.push_back(group->id()); + } + + CHECK(scene.groups().size() == groupCount); + + for (std::size_t index = 0; index < groupIds.size(); ++index) { + auto const &groupId = groupIds[index]; + auto groupIt = scene.groups().find(groupId); + REQUIRE(groupIt != scene.groups().end()); + + auto const &group = groupIt->second; + REQUIRE(group); + + auto const nodeIds = group->nodeIDs(); + CHECK(toNodeIdSet(nodeIds) == toNodeIdSet(expectedNodeIds[index])); + + for (auto *node : group->childNodes()) { + REQUIRE(node); + auto nodeGroup = node->nodeGroup().lock(); + REQUIRE(nodeGroup); + CHECK(nodeGroup->id() == groupId); + } + } + } +} + +TEST_CASE("Adding and removing nodes from a group", "[node-group]") +{ + auto app = applicationSetup(); + + auto registry = createDummyRegistry(); + DataFlowGraphModel model(registry); + BasicGraphicsScene scene(model); + scene.setGroupingEnabled(true); + + SECTION("Adding a node to an existing group") + { + NodeId firstNodeId = createNode(model, scene); + auto *firstNode = scene.nodeGraphicsObject(firstNodeId); + REQUIRE(firstNode != nullptr); + + std::vector nodes{firstNode}; + auto group = scene.createGroup(nodes, QStringLiteral("ExtendableGroup")).lock(); + REQUIRE(group); + + NodeId extraNodeId = createNode(model, scene); + auto *extraNode = scene.nodeGraphicsObject(extraNodeId); + REQUIRE(extraNode != nullptr); + + scene.addNodeToGroup(extraNodeId, group->id()); + + auto const groupIds = group->nodeIDs(); + CHECK(groupIds.size() == 2); + CHECK(std::find(groupIds.begin(), groupIds.end(), extraNodeId) != groupIds.end()); + + auto nodeGroup = extraNode->nodeGroup().lock(); + REQUIRE(nodeGroup); + CHECK(nodeGroup->id() == group->id()); + } + + SECTION("Removing nodes from a group and clearing empty groups") + { + std::vector nodes; + std::vector nodeIds; + nodes.reserve(2); + nodeIds.reserve(2); + + for (int i = 0; i < 2; ++i) { + NodeId id = createNode(model, scene); + nodeIds.push_back(id); + nodes.push_back(scene.nodeGraphicsObject(id)); + } + + auto group = scene.createGroup(nodes, QStringLiteral("RemovableGroup")).lock(); + REQUIRE(group); + auto groupId = group->id(); + + scene.removeNodeFromGroup(nodeIds.front()); + auto const remainingIds = group->nodeIDs(); + CHECK(std::find(remainingIds.begin(), remainingIds.end(), nodeIds.front()) + == remainingIds.end()); + + auto *removedNode = scene.nodeGraphicsObject(nodeIds.front()); + REQUIRE(removedNode != nullptr); + CHECK(removedNode->nodeGroup().expired()); + + scene.removeNodeFromGroup(nodeIds.back()); + CHECK(scene.groups().find(groupId) == scene.groups().end()); + } + + SECTION("Deleting grouped nodes updates the scene") + { + std::vector nodes; + std::vector nodeIds; + nodes.reserve(2); + nodeIds.reserve(2); + + for (int i = 0; i < 2; ++i) { + NodeId id = createNode(model, scene); + nodeIds.push_back(id); + nodes.push_back(scene.nodeGraphicsObject(id)); + } + + auto group = scene.createGroup(nodes, QStringLiteral("DeletionGroup")).lock(); + REQUIRE(group); + auto groupId = group->id(); + + model.deleteNode(nodeIds.front()); + QCoreApplication::processEvents(); + + CHECK(scene.groups().find(groupId) != scene.groups().end()); + + model.deleteNode(nodeIds.back()); + QCoreApplication::processEvents(); + + CHECK(scene.groups().find(groupId) == scene.groups().end()); + CHECK(scene.nodeGraphicsObject(nodeIds.front()) == nullptr); + CHECK(scene.nodeGraphicsObject(nodeIds.back()) == nullptr); + } +} + +TEST_CASE("Saving and restoring node groups", "[node-group]") +{ + auto app = applicationSetup(); + + auto registry = createDummyRegistry(); + DataFlowGraphModel model(registry); + BasicGraphicsScene scene(model); + scene.setGroupingEnabled(true); + + SECTION("Saving a group serializes nodes and connections") + { + std::vector nodeObjects; + std::vector nodeIds; + nodeObjects.reserve(2); + nodeIds.reserve(2); + + for (int i = 0; i < 2; ++i) { + NodeId nodeId = createNode(model, scene); + nodeIds.push_back(nodeId); + nodeObjects.push_back(scene.nodeGraphicsObject(nodeId)); + model.setNodeData(nodeId, NodeRole::Position, QPointF(100.0 * i, 50.0 * i)); + } + + auto group = scene.createGroup(nodeObjects, QStringLiteral("SerializableGroup")).lock(); + REQUIRE(group); + + ConnectionId connection{nodeIds[0], 0, nodeIds[1], 0}; + model.addConnection(connection); + QCoreApplication::processEvents(); + + auto groupJson = QJsonDocument::fromJson(group->saveToFile()).object(); + CHECK(groupJson["name"].toString() == QStringLiteral("SerializableGroup")); + CHECK(static_cast(groupJson["id"].toInt()) == group->id()); + + auto nodesJson = groupJson["nodes"].toArray(); + CHECK(nodesJson.size() == 2); + + std::set serializedIds; + for (auto const &nodeValue : nodesJson) { + auto nodeObject = nodeValue.toObject(); + NodeId serializedId = static_cast(nodeObject["id"].toInt()); + serializedIds.insert(serializedId); + CHECK(nodeObject.contains("position")); + } + CHECK(serializedIds == toNodeIdSet(nodeIds)); + + auto connectionsJson = groupJson["connections"].toArray(); + CHECK(connectionsJson.size() == 1); + + auto connectionObject = connectionsJson.first().toObject(); + CHECK(static_cast(connectionObject["outNodeId"].toInt()) == nodeIds[0]); + CHECK(static_cast(connectionObject["inNodeId"].toInt()) == nodeIds[1]); + } + + SECTION("Restoring a group from serialized data") + { + std::vector nodeObjects; + std::vector nodeIds; + nodeObjects.reserve(2); + nodeIds.reserve(2); + + for (int i = 0; i < 2; ++i) { + NodeId nodeId = createNode(model, scene); + nodeIds.push_back(nodeId); + nodeObjects.push_back(scene.nodeGraphicsObject(nodeId)); + model.setNodeData(nodeId, NodeRole::Position, QPointF(150.0 * i, 60.0 * i)); + } + + auto group = scene.createGroup(nodeObjects, QStringLiteral("OriginalGroup")).lock(); + REQUIRE(group); + + ConnectionId connection{nodeIds[0], 0, nodeIds[1], 0}; + model.addConnection(connection); + QCoreApplication::processEvents(); + + auto groupJson = QJsonDocument::fromJson(group->saveToFile()).object(); + + auto newRegistry = createDummyRegistry(); + DataFlowGraphModel newModel(newRegistry); + BasicGraphicsScene newScene(newModel); + newScene.setGroupingEnabled(true); + + auto [restoredGroupWeak, idMapping] = newScene.restoreGroup(groupJson); + auto restoredGroup = restoredGroupWeak.lock(); + REQUIRE(restoredGroup); + + CHECK(newScene.groups().find(restoredGroup->id()) != newScene.groups().end()); + + auto restoredIds = restoredGroup->nodeIDs(); + CHECK(restoredIds.size() == nodeIds.size()); + + for (auto originalId : nodeIds) { + auto mappingIt = idMapping.find(static_cast(originalId)); + REQUIRE(mappingIt != idMapping.end()); + NodeId restoredId = static_cast(mappingIt->second); + CHECK(std::find(restoredIds.begin(), restoredIds.end(), restoredId) + != restoredIds.end()); + } + + REQUIRE_FALSE(restoredIds.empty()); + auto connections = newModel.allConnectionIds(restoredIds.front()); + REQUIRE_FALSE(connections.empty()); + + auto connectionIt = connections.begin(); + std::set restoredSet(restoredIds.begin(), restoredIds.end()); + CHECK(restoredSet.count(connectionIt->outNodeId) == 1); + CHECK(restoredSet.count(connectionIt->inNodeId) == 1); + } +} diff --git a/test/src/TestNodeValidation.cpp b/test/src/TestNodeValidation.cpp new file mode 100644 index 000000000..b7b3b96e7 --- /dev/null +++ b/test/src/TestNodeValidation.cpp @@ -0,0 +1,151 @@ +#include + +#include +#include +#include + +#include + +using QtNodes::DataFlowGraphModel; +using QtNodes::NodeDelegateModel; +using QtNodes::NodeDelegateModelRegistry; +using QtNodes::NodeId; +using QtNodes::NodeProcessingStatus; +using QtNodes::NodeRole; +using QtNodes::NodeValidationState; +using QtNodes::PortIndex; +using QtNodes::PortType; + +/// Test delegate model that exposes validation and status setters +class TestValidatedModel : public NodeDelegateModel +{ + Q_OBJECT +public: + QString caption() const override { return "Test Validated"; } + QString name() const override { return "TestValidated"; } + + unsigned int nPorts(PortType portType) const override + { + return portType == PortType::In ? 1 : 1; + } + + QtNodes::NodeDataType dataType(PortType, PortIndex) const override + { + return QtNodes::NodeDataType{"test", "Test"}; + } + + std::shared_ptr outData(PortIndex) override { return nullptr; } + + void setInData(std::shared_ptr, PortIndex) override {} + + QWidget *embeddedWidget() override { return nullptr; } + + // Expose validation methods for testing + void setTestValidationState(NodeValidationState::State state, QString const &message) + { + NodeValidationState vs; + vs._state = state; + vs._stateMessage = message; + setValidationState(vs); + } + + void setTestProcessingStatus(NodeProcessingStatus status) { setNodeProcessingStatus(status); } +}; + +TEST_CASE("NodeValidationState basic functionality", "[validation]") +{ + SECTION("Default validation state is Valid") + { + NodeValidationState state; + CHECK(state.isValid()); + CHECK(state.state() == NodeValidationState::State::Valid); + CHECK(state.message().isEmpty()); + } + + SECTION("Validation state can be set to Warning") + { + NodeValidationState state; + state._state = NodeValidationState::State::Warning; + state._stateMessage = "Test warning"; + + CHECK_FALSE(state.isValid()); + CHECK(state.state() == NodeValidationState::State::Warning); + CHECK(state.message() == "Test warning"); + } + + SECTION("Validation state can be set to Error") + { + NodeValidationState state; + state._state = NodeValidationState::State::Error; + state._stateMessage = "Test error"; + + CHECK_FALSE(state.isValid()); + CHECK(state.state() == NodeValidationState::State::Error); + CHECK(state.message() == "Test error"); + } +} + +TEST_CASE("NodeDelegateModel validation and status", "[validation]") +{ + auto registry = std::make_shared(); + registry->registerModel("Test"); + + DataFlowGraphModel model(registry); + NodeId nodeId = model.addNode("TestValidated"); + + REQUIRE(model.nodeExists(nodeId)); + + SECTION("Default processing status is NoStatus") + { + auto status = model.nodeData(nodeId, NodeRole::ProcessingStatus); + // Check that we get a valid variant (may be default status) + CHECK(status.isValid()); + } + + SECTION("Default validation state is Valid") + { + auto state = model.nodeData(nodeId, NodeRole::ValidationState); + CHECK(state.isValid()); + } + + SECTION("Model exposes delegate for status modification") + { + auto *delegate = model.delegateModel(nodeId); + REQUIRE(delegate != nullptr); + + // Set processing status + delegate->setTestProcessingStatus(NodeProcessingStatus::Processing); + CHECK(delegate->processingStatus() == NodeProcessingStatus::Processing); + + delegate->setTestProcessingStatus(NodeProcessingStatus::Updated); + CHECK(delegate->processingStatus() == NodeProcessingStatus::Updated); + + delegate->setTestProcessingStatus(NodeProcessingStatus::Failed); + CHECK(delegate->processingStatus() == NodeProcessingStatus::Failed); + } + + SECTION("Validation state can be set through delegate") + { + auto *delegate = model.delegateModel(nodeId); + REQUIRE(delegate != nullptr); + + delegate->setTestValidationState(NodeValidationState::State::Error, "Invalid input"); + + auto state = delegate->validationState(); + CHECK(state.state() == NodeValidationState::State::Error); + CHECK(state.message() == "Invalid input"); + } +} + +TEST_CASE("NodeProcessingStatus enum values", "[validation]") +{ + CHECK(static_cast(NodeProcessingStatus::NoStatus) == 0); + CHECK(static_cast(NodeProcessingStatus::Updated) == 1); + CHECK(static_cast(NodeProcessingStatus::Processing) == 2); + CHECK(static_cast(NodeProcessingStatus::Pending) == 3); + CHECK(static_cast(NodeProcessingStatus::Empty) == 4); + CHECK(static_cast(NodeProcessingStatus::Failed) == 5); + CHECK(static_cast(NodeProcessingStatus::Partial) == 6); +} + +#include "TestNodeValidation.moc" diff --git a/test/src/TestZoomFeatures.cpp b/test/src/TestZoomFeatures.cpp new file mode 100644 index 000000000..90fd2e3aa --- /dev/null +++ b/test/src/TestZoomFeatures.cpp @@ -0,0 +1,205 @@ +#include "ApplicationSetup.hpp" +#include "TestGraphModel.hpp" + +#include + +#include +#include +#include + +#include +#include + +using QtNodes::BasicGraphicsScene; +using QtNodes::GraphicsView; +using QtNodes::NodeId; +using QtNodes::NodeRole; + +TEST_CASE("GraphicsView scale range", "[zoom]") +{ + auto app = applicationSetup(); + + auto model = std::make_shared(); + BasicGraphicsScene scene(*model); + GraphicsView view(&scene); + + view.resize(800, 600); + view.show(); + REQUIRE(QTest::qWaitForWindowExposed(&view)); + + SECTION("Default scale range allows unlimited zoom") + { + // By default, scale range should not limit zooming + double initialScale = view.getScale(); + CHECK(initialScale > 0); + + // Zoom in multiple times + for (int i = 0; i < 10; ++i) { + view.scaleUp(); + } + + CHECK(view.getScale() > initialScale); + } + + SECTION("Scale range can be set with minimum and maximum") + { + view.setScaleRange(0.5, 2.0); + + // Set scale to middle value + view.setupScale(1.0); + CHECK(view.getScale() == Approx(1.0).epsilon(0.01)); + + // Try to zoom out beyond minimum + view.setupScale(0.1); + CHECK(view.getScale() >= 0.5); + + // Try to zoom in beyond maximum + view.setupScale(5.0); + CHECK(view.getScale() <= 2.0); + } + + SECTION("Scale range can be set with ScaleRange struct") + { + GraphicsView::ScaleRange range{0.25, 4.0}; + view.setScaleRange(range); + + view.setupScale(0.1); + CHECK(view.getScale() >= 0.25); + + view.setupScale(10.0); + CHECK(view.getScale() <= 4.0); + } +} + +TEST_CASE("scaleChanged signal", "[zoom]") +{ + auto app = applicationSetup(); + + auto model = std::make_shared(); + BasicGraphicsScene scene(*model); + GraphicsView view(&scene); + + view.resize(800, 600); + view.show(); + REQUIRE(QTest::qWaitForWindowExposed(&view)); + + SECTION("Signal emitted on scale change") + { + QSignalSpy spy(&view, &GraphicsView::scaleChanged); + + view.scaleUp(); + QCoreApplication::processEvents(); + + // Signal should have been emitted + CHECK(spy.count() >= 1); + + // Check signal argument + if (spy.count() > 0) { + QList arguments = spy.takeFirst(); + double scale = arguments.at(0).toDouble(); + CHECK(scale > 0); + } + } + + SECTION("Signal emitted with correct scale value") + { + QSignalSpy spy(&view, &GraphicsView::scaleChanged); + + view.setupScale(1.5); + QCoreApplication::processEvents(); + + CHECK(spy.count() >= 1); + + if (spy.count() > 0) { + QList arguments = spy.takeLast(); + double scale = arguments.at(0).toDouble(); + CHECK(scale == Approx(1.5).epsilon(0.01)); + } + } +} + +TEST_CASE("Zoom fit operations", "[zoom]") +{ + auto app = applicationSetup(); + + auto model = std::make_shared(); + BasicGraphicsScene scene(*model); + GraphicsView view(&scene); + + view.resize(800, 600); + view.show(); + REQUIRE(QTest::qWaitForWindowExposed(&view)); + + SECTION("zoomFitAll with nodes") + { + // Create some nodes spread across the scene + NodeId node1 = model->addNode("Node1"); + model->setNodeData(node1, NodeRole::Position, QPointF(-500, -500)); + + NodeId node2 = model->addNode("Node2"); + model->setNodeData(node2, NodeRole::Position, QPointF(500, 500)); + + QCoreApplication::processEvents(); + + double scaleBefore = view.getScale(); + + // Fit all nodes + view.zoomFitAll(); + QCoreApplication::processEvents(); + + // Scale should have changed (either up or down to fit) + double scaleAfter = view.getScale(); + // Just check we didn't crash and scale is still valid + CHECK(scaleAfter > 0); + } + + SECTION("zoomFitSelected with selected nodes") + { + // Create nodes + NodeId node1 = model->addNode("Node1"); + model->setNodeData(node1, NodeRole::Position, QPointF(0, 0)); + + NodeId node2 = model->addNode("Node2"); + model->setNodeData(node2, NodeRole::Position, QPointF(200, 200)); + + QCoreApplication::processEvents(); + + // Select one node + auto *nodeGraphics = scene.nodeGraphicsObject(node1); + REQUIRE(nodeGraphics != nullptr); + nodeGraphics->setSelected(true); + + QCoreApplication::processEvents(); + + // Fit selected + view.zoomFitSelected(); + QCoreApplication::processEvents(); + + // Just check we didn't crash + CHECK(view.getScale() > 0); + } + + SECTION("zoomFitAll with empty scene") + { + // Empty scene - should not crash + view.zoomFitAll(); + QCoreApplication::processEvents(); + + CHECK(view.getScale() > 0); + } + + SECTION("zoomFitSelected with no selection") + { + NodeId node1 = model->addNode("Node1"); + model->setNodeData(node1, NodeRole::Position, QPointF(0, 0)); + + QCoreApplication::processEvents(); + + // Don't select anything + view.zoomFitSelected(); + QCoreApplication::processEvents(); + + // Should not crash + CHECK(view.getScale() > 0); + } +}