feat(qt6): visual workflow canvas with infinite pan/zoom, Bezier connections, 152-node palette

Replace list-based WorkflowEditor with spatial infinite canvas:
- NodeRegistry C++ class loads 152 node types from node-registry.json
- 5000x5000 Flickable canvas with grid, Scale transform zoom (0.25x-2x)
- Draggable nodes with group-colored headers, input/output ports
- Bezier connection drawing via Canvas 2D (n8n adjacency map format)
- Interactive port-to-port connection creation with drag preview
- Searchable/filterable node palette with group chips
- Properties panel with schema-driven parameter editing
- Full n8n-style JSON: nodes, connections, variables, meta, tags
- DBAL integration preserved with mock workflow fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 03:03:34 +00:00
parent e0893c2fe3
commit e6a2a50ae1
8 changed files with 1676 additions and 535 deletions

View File

@@ -1,3 +1,30 @@
# AUTO-GENERATED by generate_cmake.py — do not edit manually
# Generated from cmake_config.json | 91 QML files, 6 C++ sources, 22 SVGs, 1 audio assets
#
# Discovered packages:
# analytics v1.0.0 - Analytics Studio
# blog v1.0.0 - Blog
# breakout v1.0.0 - Breakout
# connection_hub v1.0.0 - Connection Hub
# escape_room v1.0.0 - Escape Room
# forum v1.0.0 - Community Forum
# frontpage v1.0.0 - Frontpage Experience
# gallery v1.0.0 - Gallery
# god_panel v1.0.0 - God Panel
# guestbook v1.0.0 - Guestbook
# login v1.0.0 - Login Shell
# marketplace v1.0.0 - Marketplace
# microthread v1.0.0 - MicroThread
# music_player v1.0.0 - Music Player
# package_manager v1.0.0 - Package Manager
# profile_page v1.0.0 - Profile Page
# retro_games v1.0.0 - Retro Arcade
# snake_game v1.0.0 - Snake Game
# storybook v1.0.0 - Storybook Showcase
# supergod_panel v1.0.0 - SuperGod Panel
# user_settings v1.0.0 - User Settings
# watchtower v1.0.0 - Watchtower
cmake_minimum_required(VERSION 3.27)
project(dbal_qml VERSION 0.1 LANGUAGES CXX)
@@ -18,54 +45,118 @@ qt_policy(SET QTP0001 NEW)
qt_add_executable(dbal-qml
main.cpp
src/PackageRegistry.cpp
src/ModPlayer.cpp
src/DBALClient.cpp
src/ModPlayer.cpp
src/NodeRegistry.cpp
src/PackageLoader.cpp
src/PackageRegistry.cpp
)
# Pass source dir so the binary can find shared QML components at runtime
target_compile_definitions(dbal-qml PRIVATE SRCDIR="${CMAKE_CURRENT_SOURCE_DIR}")
qt_add_qml_module(dbal-qml
URI DBALObservatory
VERSION 1.0
QML_FILES
App.qml
FrontPage.qml
LoginView.qml
DashboardView.qml
AdminView.qml
PackageViewLoader.qml
ProfileView.qml
App.qml
CommentsView.qml
GodPanel.qml
SchemaEditor.qml
WorkflowEditor.qml
LuaEditor.qml
DatabaseManager.qml
PageRoutesManager.qml
ComponentHierarchyEditor.qml
CssClassManager.qml
DashboardView.qml
DatabaseManager.qml
DropdownConfigManager.qml
UserManagement.qml
ThemeEditor.qml
SMTPConfigEditor.qml
SuperGodPanel.qml
FrontPage.qml
GodPanel.qml
LoginView.qml
LuaEditor.qml
MaterialLanding.qml
ModPlayerPanel.qml
PackageManager.qml
Storybook.qml
MediaServicePanel.qml
ModPlayerPanel.qml
NotificationsPanel.qml
PackageManager.qml
PackageViewLoader.qml
PageRoutesManager.qml
ProfileView.qml
SMTPConfigEditor.qml
SchemaEditor.qml
SettingsView.qml
Storybook.qml
SuperGodPanel.qml
ThemeEditor.qml
UserManagement.qml
WorkflowEditor.qml
qmllib/Material/MaterialAccordion.qml
qmllib/Material/MaterialAlert.qml
qmllib/Material/MaterialAppBar.qml
qmllib/Material/MaterialAvatar.qml
qmllib/Material/MaterialBadge.qml
qmllib/Material/MaterialBox.qml
qmllib/Material/MaterialButton.qml
qmllib/Material/MaterialCard.qml
qmllib/Material/MaterialCheckbox.qml
qmllib/Material/MaterialChip.qml
qmllib/Material/MaterialCircularProgress.qml
qmllib/Material/MaterialCollapse.qml
qmllib/Material/MaterialContainer.qml
qmllib/Material/MaterialDialog.qml
qmllib/Material/MaterialDivider.qml
qmllib/Material/MaterialDividerProps.qml
qmllib/Material/MaterialGrid.qml
qmllib/Material/MaterialIconButton.qml
qmllib/Material/MaterialLinearProgress.qml
qmllib/Material/MaterialLink.qml
qmllib/Material/MaterialMenu.qml
qmllib/Material/MaterialMenuItem.qml
qmllib/Material/MaterialMenuProps.qml
qmllib/Material/MaterialPalette.qml
qmllib/Material/MaterialPaper.qml
qmllib/Material/MaterialPopover.qml
qmllib/Material/MaterialPopoverProps.qml
qmllib/Material/MaterialSkeleton.qml
qmllib/Material/MaterialSnackbar.qml
qmllib/Material/MaterialSurface.qml
qmllib/Material/MaterialSwitch.qml
qmllib/Material/MaterialTextField.qml
qmllib/Material/MaterialToolbar.qml
qmllib/Material/MaterialTypography.qml
qmllib/MetaBuilder/ContactForm.qml
qmllib/MetaBuilder/FeatureCard.qml
qmllib/MetaBuilder/HeroSection.qml
qmllib/MetaBuilder/NavBar.qml
qmllib/MetaBuilder/StatusCard.qml
qmllib/MetaBuilder/WorkflowNode.qml
qmllib/dbal/DBALProvider.qml
packages/analytics/PackageView.qml
packages/blog/PackageView.qml
packages/breakout/PackageView.qml
packages/connection-hub/PackageView.qml
packages/escape-room/PackageView.qml
packages/forum/PackageView.qml
packages/frontpage/PackageView.qml
packages/gallery/PackageView.qml
packages/god-panel/PackageView.qml
packages/guestbook/PackageView.qml
packages/login/PackageView.qml
packages/marketplace/PackageView.qml
packages/microthread/PackageView.qml
packages/music-player/PackageView.qml
packages/package-manager/PackageView.qml
packages/profile-page/PackageView.qml
packages/retro-games/PackageView.qml
packages/snake-game/PackageView.qml
packages/storybook/PackageView.qml
packages/supergod-panel/PackageView.qml
packages/user-settings/PackageView.qml
packages/watchtower/PackageView.qml
RESOURCES
assets/audio/retro-gaming.mod
qmllib/Material/qmldir
qmllib/MetaBuilder/qmldir
qmllib/dbal/qmldir
)
# Add SVG assets
# SVG assets
file(GLOB SVG_ASSETS RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} assets/svg/*.svg)
qt_add_resources(dbal-qml "svg_assets"
PREFIX "/"
@@ -76,9 +167,7 @@ target_link_libraries(dbal-qml PRIVATE
Qt6::Core Qt6::Gui Qt6::Quick Qt6::Qml Qt6::Network
)
# Conan Qt recipe adds module-specific include dirs to CMAKE_INCLUDE_PATH
# but MSVC needs them as actual compiler include paths.
# Add the Qt root include + all module subdirs from CMAKE_INCLUDE_PATH.
# Conan Qt recipe: propagate CMAKE_INCLUDE_PATH entries for MSVC
foreach(_inc ${CMAKE_INCLUDE_PATH})
target_include_directories(dbal-qml PRIVATE "${_inc}")
endforeach()

107
frontends/qt6/PLAN.md Normal file
View File

@@ -0,0 +1,107 @@
# Phase 9: Visual Workflow Canvas + Frontend Parity
**Status**: In Progress
**Started**: 2026-03-19
---
## Part A: QML Workflow Canvas (PRIMARY)
Replace the list-based WorkflowEditor with a spatial infinite canvas.
### Steps
- [x] **Step 1: NodeRegistry C++ class**
- New `src/NodeRegistry.h` + `src/NodeRegistry.cpp`
- Reads `workflow/plugins/registry/node-registry.json`
- Exposes `nodeTypes()`, `groups()`, `nodeType(name)`, `nodesByGroup()`, `searchNodes()` to QML
- Registered as context property `NodeRegistry` in `main.cpp`
- Auto-discovered by `generate_cmake.py` (6 C++ sources total)
- [x] **Step 2: Canvas Infrastructure**
- `Flickable` 5000x5000 with grid background
- `Scale` transform for zoom (0.25x2x)
- Mousewheel zoom, zoom overlay with +/- buttons
- `Canvas` 2D connection layer + `Repeater` node layer
- [x] **Step 3: WorkflowNode Component**
- `qmllib/MetaBuilder/WorkflowNode.qml` with DragHandler
- Colored header by group (prefix-based color mapping)
- Input ports (left, blue), output ports (right, green)
- Click-to-select with visual highlight
- Inline node delegates in WorkflowEditor for tight integration
- [x] **Step 4: Bezier Connection Drawing**
- QML `Canvas` with `context.bezierCurveTo()`
- Parses n8n-style `connections` adjacency map
- Control points offset 40% of horizontal distance
- Arrow heads at destination ports
- Dashed line for connection being drawn
- `requestPaint()` on node drag and connection changes
- [x] **Step 5: Node Palette (Left Sidebar)**
- ListView from `NodeRegistry.nodeTypes` filtered by search + group
- Group filter chips (All, core, logic, transform, integration, etc.)
- Double-click to add at canvas center
- `Drag.active` + `DropArea` for drag-to-canvas
- [x] **Step 6: Properties Panel (Right Sidebar)**
- Animated slide-in panel (300px)
- Name (editable), Type (badge with group color)
- Parameters from `NodeRegistry.nodeType()` schema
- Dynamic text fields and dropdowns for property options
- Input/output port display with chips
- Workflow variables display
- Position readout, Delete button
- [x] **Step 7: Workflow I/O**
- Full n8n-style JSON: name, active, settings, tags, meta, variables, nodes, connections
- DBAL load/save via `DBALProvider.list/create/update/remove`
- Mock workflows with realistic graph layouts as fallback
- Add/remove nodes + connections with proper cleanup
### Files
| File | Action |
|------|--------|
| `WorkflowEditor.qml` | Full rewrite — spatial canvas |
| `qmllib/MetaBuilder/WorkflowNode.qml` | New — draggable node component |
| `src/NodeRegistry.h` | New — C++ node type loader |
| `src/NodeRegistry.cpp` | New — implementation |
| `main.cpp` | Register NodeRegistry context property |
| `CMakeLists.txt` | Add NodeRegistry to sources |
### Verification
1. Build compiles clean
2. God Panel → Workflows tab shows canvas
3. Pan (scroll) + zoom (Ctrl+scroll) works
4. Load `seed_game.json` (58 nodes) renders correctly
5. Drag from palette → node on canvas
6. Port-to-port connection drawing
7. Properties panel for selected node
8. Save roundtrip to DBAL
---
## Part B: Next.js Frontend Alignment (LATER)
- [ ] Level-based layout (5 levels matching `old/`)
- [ ] Sidebar navigation matching Qt6
- [ ] God Panel tabs
- [ ] Wire in workflowui editor
## Part C: CLI Feature Parity (LATER)
- [ ] `workflow list/get/run/create` commands
- [ ] `package list/install/uninstall` commands
---
## Reusable Code
- `DBALProvider.qml` — DBAL REST client (keep as-is)
- `PackageLoader` C++ pattern — template for NodeRegistry
- `node-registry.json` — 152 node types
- `components/workflow-editor/ConnectionLine.tsx` — Bezier math reference
- `gameengine/packages/seed/workflows/*.json` — test data

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@
#include "src/ModPlayer.h"
#include "src/DBALClient.h"
#include "src/PackageLoader.h"
#include "src/NodeRegistry.h"
int main(int argc, char *argv[]) {
QGuiApplication app(argc, argv);
@@ -16,15 +17,20 @@ int main(int argc, char *argv[]) {
// Add shared QML component library path
// Resolves: import QmlComponents 1.0
const auto appDir = QCoreApplication::applicationDirPath();
const QStringList qmlPaths = {
appDir + "/../../qml",
appDir + "/../../../qml",
appDir + "/../../../../qml",
QDir::cleanPath(QStringLiteral(SRCDIR) + "/../../qml")
// Qt6 resolves "import QmlComponents" by looking for a QmlComponents/ dir
// inside each import path. We symlink or reference the parent of qml/.
const QStringList qmlParentPaths = {
appDir + "/../../",
appDir + "/../../../",
appDir + "/../../../../",
QDir::cleanPath(QStringLiteral(SRCDIR) + "/../..")
};
for (const auto &path : qmlPaths) {
if (QDir(path).exists()) {
engine.addImportPath(QDir(path).absolutePath());
for (const auto &path : qmlParentPaths) {
const QString candidate = QDir(path).absolutePath();
// Check if QmlComponents symlink or qml/ dir with qmldir exists
if (QDir(candidate + "/QmlComponents").exists()
|| QDir(candidate + "/qml").exists()) {
engine.addImportPath(candidate);
break;
}
}
@@ -33,16 +39,24 @@ int main(int argc, char *argv[]) {
ModPlayer modPlayer;
DBALClient dbalClient;
PackageLoader packageLoader;
NodeRegistry nodeRegistry;
registry.loadPackage("frontpage");
packageLoader.setPackagesDir(QDir(QStringLiteral(SRCDIR) + QStringLiteral("/packages")).absolutePath());
packageLoader.scan();
packageLoader.setWatching(true);
// Load workflow node type registry
const QString registryPath = QDir::cleanPath(
QStringLiteral(SRCDIR) + QStringLiteral("/../../workflow/plugins/registry/node-registry.json"));
nodeRegistry.loadRegistry(registryPath);
engine.rootContext()->setContextProperty(QStringLiteral("PackageRegistry"), &registry);
engine.rootContext()->setContextProperty(QStringLiteral("ModPlayer"), &modPlayer);
engine.rootContext()->setContextProperty(QStringLiteral("DBALClient"), &dbalClient);
engine.rootContext()->setContextProperty(QStringLiteral("PackageLoader"), &packageLoader);
engine.rootContext()->setContextProperty(QStringLiteral("NodeRegistry"), &nodeRegistry);
const QUrl url(QStringLiteral("qrc:/DBALObservatory/App.qml"));
const QUrl url(QStringLiteral("qrc:/qt/qml/DBALObservatory/App.qml"));
QObject::connect(&engine, &QQmlApplicationEngine::objectCreated,
&app, [url](QObject *obj, const QUrl &objUrl) {
if (!obj && objUrl == url)

View File

@@ -0,0 +1,214 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QmlComponents 1.0
Rectangle {
id: nodeRoot
property string nodeId: ""
property string nodeName: "Node"
property string nodeType: "action"
property string displayName: ""
property var nodeInputs: []
property var nodeOutputs: []
property var parameters: ({})
property real nodeX: 0
property real nodeY: 0
property bool selected: false
property real zoom: 1.0
// Port geometry constants
readonly property int portRadius: 6
readonly property int portSpacing: 24
readonly property int headerHeight: 32
readonly property int minWidth: 180
readonly property int minHeight: headerHeight + Math.max(nodeInputs.length, nodeOutputs.length) * portSpacing + 16
signal moved(string id, real newX, real newY)
signal clicked(string id)
signal doubleClicked(string id)
signal portPressed(string nodeId, string portName, string portType, bool isOutput, real globalX, real globalY)
signal portReleased(string nodeId, string portName, string portType, bool isOutput, real globalX, real globalY)
x: nodeX
y: nodeY
width: minWidth
height: minHeight
radius: 8
color: selected ? Qt.lighter(Theme.paper, 1.1) : Theme.paper
border.color: selected ? groupColor() : Theme.border
border.width: selected ? 2 : 1
layer.enabled: true
layer.effect: null
function groupColor() {
switch (nodeType.split(".")[0]) {
case "metabuilder": return Theme.success
case "logic": return Theme.warning
case "transform":
case "packagerepo": return "#FF9800"
case "sdl":
case "graphics": return "#2196F3"
case "integration": return "#9C27B0"
case "io": return "#00BCD4"
default: return Theme.primary
}
}
// Header bar
Rectangle {
id: header
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: headerHeight
radius: 8
color: groupColor()
// Square off bottom corners
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: parent.radius
color: parent.color
}
CText {
anchors.centerIn: parent
text: displayName || nodeName
color: "#FFFFFF"
variant: "body2"
font.bold: true
elide: Text.ElideRight
width: parent.width - 16
horizontalAlignment: Text.AlignHCenter
}
}
// Drag handler on the header
DragHandler {
id: dragHandler
target: nodeRoot
onActiveChanged: {
if (!active) {
nodeRoot.moved(nodeId, nodeRoot.x, nodeRoot.y)
}
}
}
// Click handler
TapHandler {
onTapped: nodeRoot.clicked(nodeId)
onDoubleTapped: nodeRoot.doubleClicked(nodeId)
}
// Input ports (left side)
Column {
anchors.left: parent.left
anchors.leftMargin: -portRadius
anchors.top: header.bottom
anchors.topMargin: 8
spacing: portSpacing - portRadius * 2
Repeater {
model: nodeInputs
delegate: Item {
width: portRadius * 2 + 60
height: portRadius * 2
Rectangle {
id: inputPort
width: portRadius * 2
height: portRadius * 2
radius: portRadius
color: Theme.primary
border.color: Theme.background
border.width: 1
MouseArea {
anchors.fill: parent
anchors.margins: -4
hoverEnabled: true
cursorShape: Qt.CrossCursor
onPressed: function(mouse) {
var global = inputPort.mapToItem(null, portRadius, portRadius)
nodeRoot.portPressed(nodeId, modelData.name, modelData.type, false, global.x, global.y)
}
onReleased: function(mouse) {
var global = inputPort.mapToItem(null, portRadius, portRadius)
nodeRoot.portReleased(nodeId, modelData.name, modelData.type, false, global.x, global.y)
}
}
}
CText {
anchors.left: inputPort.right
anchors.leftMargin: 4
anchors.verticalCenter: inputPort.verticalCenter
text: modelData.displayName || modelData.name || "in"
variant: "caption"
font.pixelSize: 10
}
}
}
}
// Output ports (right side)
Column {
anchors.right: parent.right
anchors.rightMargin: -portRadius
anchors.top: header.bottom
anchors.topMargin: 8
spacing: portSpacing - portRadius * 2
Repeater {
model: nodeOutputs
delegate: Item {
width: portRadius * 2 + 60
height: portRadius * 2
layoutDirection: Qt.RightToLeft
CText {
anchors.right: outputPort.left
anchors.rightMargin: 4
anchors.verticalCenter: outputPort.verticalCenter
text: modelData.displayName || modelData.name || "out"
variant: "caption"
font.pixelSize: 10
horizontalAlignment: Text.AlignRight
}
Rectangle {
id: outputPort
anchors.right: parent.right
width: portRadius * 2
height: portRadius * 2
radius: portRadius
color: Theme.success
border.color: Theme.background
border.width: 1
MouseArea {
anchors.fill: parent
anchors.margins: -4
hoverEnabled: true
cursorShape: Qt.CrossCursor
onPressed: function(mouse) {
var global = outputPort.mapToItem(null, portRadius, portRadius)
nodeRoot.portPressed(nodeId, modelData.name, modelData.type, true, global.x, global.y)
}
onReleased: function(mouse) {
var global = outputPort.mapToItem(null, portRadius, portRadius)
nodeRoot.portReleased(nodeId, modelData.name, modelData.type, true, global.x, global.y)
}
}
}
}
}
}
}

View File

@@ -4,3 +4,4 @@ HeroSection 1.0 HeroSection.qml
FeatureCard 1.0 FeatureCard.qml
StatusCard 1.0 StatusCard.qml
ContactForm 1.0 ContactForm.qml
WorkflowNode 1.0 WorkflowNode.qml

View File

@@ -0,0 +1,111 @@
#include "NodeRegistry.h"
#include <QFile>
#include <QJsonDocument>
#include <QDebug>
#include <algorithm>
NodeRegistry::NodeRegistry(QObject *parent)
: QObject(parent)
{
}
QVariantList NodeRegistry::nodeTypes() const
{
QVariantList list;
for (auto it = m_nodeTypes.constBegin(); it != m_nodeTypes.constEnd(); ++it) {
list.append(it.value().toVariantMap());
}
return list;
}
void NodeRegistry::loadRegistry(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
m_lastError = QStringLiteral("Cannot open registry file: ") + path;
qWarning() << "NodeRegistry:" << m_lastError;
emit errorOccurred(m_lastError);
return;
}
QJsonParseError parseErr;
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseErr);
file.close();
if (parseErr.error != QJsonParseError::NoError) {
m_lastError = QStringLiteral("JSON parse error: ") + parseErr.errorString();
qWarning() << "NodeRegistry:" << m_lastError;
emit errorOccurred(m_lastError);
return;
}
parseRegistry(doc.object());
m_loaded = true;
emit loadedChanged();
emit nodeTypesChanged();
qDebug() << "NodeRegistry: loaded" << m_nodeTypes.count() << "node types in"
<< m_groups.count() << "groups from" << path;
}
void NodeRegistry::parseRegistry(const QJsonObject &root)
{
m_nodeTypes.clear();
m_groups.clear();
const QJsonArray types = root.value(QStringLiteral("nodeTypes")).toArray();
QSet<QString> groupSet;
for (const QJsonValue &val : types) {
const QJsonObject obj = val.toObject();
const QString name = obj.value(QStringLiteral("name")).toString();
if (name.isEmpty())
continue;
m_nodeTypes.insert(name, obj);
const QString group = obj.value(QStringLiteral("group")).toString();
if (!group.isEmpty())
groupSet.insert(group);
}
m_groups = groupSet.values();
m_groups.sort();
}
QVariantMap NodeRegistry::nodeType(const QString &name) const
{
if (!m_nodeTypes.contains(name))
return {};
return m_nodeTypes.value(name).toVariantMap();
}
QVariantList NodeRegistry::nodesByGroup(const QString &group) const
{
QVariantList list;
for (auto it = m_nodeTypes.constBegin(); it != m_nodeTypes.constEnd(); ++it) {
if (it.value().value(QStringLiteral("group")).toString() == group)
list.append(it.value().toVariantMap());
}
return list;
}
QVariantList NodeRegistry::searchNodes(const QString &query) const
{
if (query.isEmpty())
return nodeTypes();
const QString lower = query.toLower();
QVariantList list;
for (auto it = m_nodeTypes.constBegin(); it != m_nodeTypes.constEnd(); ++it) {
const QJsonObject &obj = it.value();
const QString name = obj.value(QStringLiteral("name")).toString().toLower();
const QString displayName = obj.value(QStringLiteral("displayName")).toString().toLower();
const QString desc = obj.value(QStringLiteral("description")).toString().toLower();
if (name.contains(lower) || displayName.contains(lower) || desc.contains(lower))
list.append(obj.toVariantMap());
}
return list;
}

View File

@@ -0,0 +1,50 @@
#ifndef NODEREGISTRY_H
#define NODEREGISTRY_H
#include <QObject>
#include <QJsonObject>
#include <QJsonArray>
#include <QString>
#include <QStringList>
#include <QMap>
#include <QVariantList>
class NodeRegistry : public QObject
{
Q_OBJECT
Q_PROPERTY(QVariantList nodeTypes READ nodeTypes NOTIFY nodeTypesChanged)
Q_PROPERTY(QStringList groups READ groups NOTIFY nodeTypesChanged)
Q_PROPERTY(int nodeCount READ nodeCount NOTIFY nodeTypesChanged)
Q_PROPERTY(bool loaded READ isLoaded NOTIFY loadedChanged)
Q_PROPERTY(QString lastError READ lastError NOTIFY errorOccurred)
public:
explicit NodeRegistry(QObject *parent = nullptr);
QVariantList nodeTypes() const;
QStringList groups() const { return m_groups; }
int nodeCount() const { return m_nodeTypes.count(); }
bool isLoaded() const { return m_loaded; }
QString lastError() const { return m_lastError; }
public slots:
void loadRegistry(const QString &path);
QVariantMap nodeType(const QString &name) const;
QVariantList nodesByGroup(const QString &group) const;
QVariantList searchNodes(const QString &query) const;
signals:
void nodeTypesChanged();
void loadedChanged();
void errorOccurred(const QString &error);
private:
void parseRegistry(const QJsonObject &root);
QMap<QString, QJsonObject> m_nodeTypes;
QStringList m_groups;
bool m_loaded = false;
QString m_lastError;
};
#endif