diff --git a/CMakeLists.txt b/CMakeLists.txt index 8f809f3..f6e49c2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,11 +15,16 @@ option(MORPH_BUILD_EXAMPLES "Build examples" ON) option(MORPH_BUILD_BANK_EXAMPLE "Build the SQLite/Lightweight bank example (heavy deps)" OFF) option(MORPH_BUILD_BANK_GUI "Build the Qt 6 GUI for the bank example" OFF) option(MORPH_BUILD_FORMS_QML "Build the Qt Quick renderer for the forms demo" OFF) +option(MORPH_BUILD_FORMS_QML_SHELL "Build the QML form shell with dynamic page loading" OFF) if(MORPH_BUILD_FORMS_QML AND (NOT MORPH_BUILD_EXAMPLES OR EMSCRIPTEN)) message(WARNING "MORPH_BUILD_FORMS_QML is ignored: it lives under the forms example, " "which needs MORPH_BUILD_EXAMPLES=ON and a non-Emscripten toolchain.") endif() +if(MORPH_BUILD_FORMS_QML_SHELL AND (NOT MORPH_BUILD_FORMS_QML OR EMSCRIPTEN)) + message(WARNING "MORPH_BUILD_FORMS_QML_SHELL requires MORPH_BUILD_FORMS_QML=ON and " + "a non-Emscripten toolchain.") +endif() option(MORPH_BUILD_QT "Build Qt6 WebSocket backend and tests" OFF) option(MORPH_BUILD_DOCUMENTATION "Build doxygen docs" OFF) diff --git a/docs/superpowers/2026-07-12-qml-shell.md b/docs/superpowers/2026-07-12-qml-shell.md new file mode 100644 index 0000000..dbaf17b --- /dev/null +++ b/docs/superpowers/2026-07-12-qml-shell.md @@ -0,0 +1,177 @@ +# QML form shell with dynamic page loading + +A Qt Quick shell that provides a tree-navigable application window for +loading and switching between built-in schema-generated forms and +user-provided QML pages. + +## Architecture + +``` +morph_forms_shell executable + └─ FormsShellController (QML_ELEMENT, context property) + ├─ PageTreeModel (QAbstractItemModel) — navigation tree + ├─ morph::bridge::Bridge + BridgeHandler — action dispatch + └─ Persistent config file (QStandardPaths::AppConfigLocation) + └─ QML: Main.qml → FormShell.qml + ├─ TreeView sidebar (page navigation) + ├─ Loader content area (dynamic page loading) + └─ PageConfigDialog.qml (tree management popup) +``` + +The shell links against the existing `morph_forms_module` (static library +with `MorphForms` QML module URI) and reuses `DynamicForm.qml` and +`DateTimePicker.qml` for built-in schema pages. + +## Page tree model + +`PageTreeModel` is a `QAbstractItemModel` that represents the navigation +hierarchy. Each node is either a **folder** (internal node, can have +children) or a **page** (leaf node, loadable as QML). + +**Node roles exposed to QML:** +- `name` — display name in the tree +- `source` — `"builtin://ActionType"` for schema forms, or a `file://` URL + for user `.qml` files +- `nodeType` — `"folder"` or `"page"` +- `visible` — whether the node appears in the sidebar + +**Q_INVOKABLE operations:** +- `addFolder(parentIndex, name)` — insert a child folder +- `addPage(parentIndex, name, source)` — insert a child page +- `removeNode(index)` — remove a node (and its subtree) +- `renameNode(index, name)` — rename a node +- `setNodeVisible(index, visible)` — toggle visibility + +## Persistence + +The tree is saved to a JSON file on disk at +`QStandardPaths::writableLocation(AppConfigLocation)` under the +`morph/morph-shell/` directory, with filename `pages.json`. The path is +configurable via `FormsShellController.configPath`. + +Format: +```json +[ + { + "name": "Built-in Forms", + "type": "folder", + "visible": true, + "children": [ + { "name": "Compute Dry Density", "type": "page", + "source": "builtin://ComputeDryDensity", "visible": true }, + { "name": "Record Measurement", "type": "page", + "source": "builtin://RecordMeasurement", "visible": true } + ] + }, + { + "name": "My Dashboard", "type": "page", + "source": "file:///home/user/dashboard.qml", "visible": true + } +] +``` + +On first launch (no config file exists), the tree is populated with the +two built-in schema forms under a "Built-in Forms" folder. + +## FormsShellController + +A `QObject` with `QML_ELEMENT` that owns the bridge stack and the page +tree model. It is also registered as a QML context property named +`morphController` so that user-loaded `.qml` files can access it. + +**Properties:** +- `pageModel` — the `PageTreeModel` instance +- `schemasJson` — `{actionType: schema}` JSON for built-in actions +- `configPath` — path to the JSON config file (default: + `/morph/morph-shell/pages.json`) + +**Q_INVOKABLE:** +- `submitIfValid(actionType, bodyJson)` — dispatches through + `BridgeHandler::executeJson` +- `fetchOptions(optionsAction)` — fetches combo-box options (for + `Choice` fields) +- `saveConfig()` — writes the current tree to disk +- `loadConfig()` — reloads the tree from disk (replaces current tree) + +**Signals:** +- `replyReceived(actionType, ok, payload)` — result of a submission +- `optionsReceived(optionsAction, ok, payload)` — result of option fetch +- `treeChanged()` — emitted when the tree structure changes + +## Built-in pages (FormPage.qml) + +`FormPage.qml` wraps a `DynamicForm` from the `MorphForms` module inside +a `ScrollView`, making it fill the content area. It receives `actionType`, +`schema`, and `controller` from the shell and delegates to the existing +reactive form logic. + +## User-provided pages + +Any `.qml` file added to the tree via the config dialog is loaded into +the content area through a `Loader { source: pageSource }`. The file +runs in the same QML engine and can access: + +- `morphController` — the `FormsShellController` instance (context + property), for calling `submitIfValid()` / `fetchOptions()` and + accessing `schemasJson` +- All `MorphForms` module components (`DynamicForm`, `DateTimePicker`) + +A minimal user page: +```qml +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ColumnLayout { + Label { text: "My Custom Page" } + Button { + text: "Run action" + onClicked: morphController.submitIfValid("ComputeDryDensity", + '{"massDry":{"num":5000,"den":1000,"dp":3},"volume":{"num":1,"den":1000,"dp":4}}') + } +} +``` + +## PageConfigDialog + +A modal dialog opened from the shell's sidebar toolbar. Shows the full +page tree with editing controls: + +- **Add Folder** — inserts a new folder node under the selected parent +- **Add Page** — opens a `QFileDialog` for `.qml` files, then adds a + page node +- **Remove** — removes the selected node (with confirmation for folders) +- **Rename** — inline editing of the node name +- **Visibility toggle** — checkbox per node to show/hide in the sidebar + +The dialog emits `accepted` when the user clicks Save, which triggers +`FormsShellController.saveConfig()`. + +## Build integration + +The shell is built when `MORPH_BUILD_FORMS_QML_SHELL=ON` (requires +`MORPH_BUILD_EXAMPLES=ON` and `MORPH_BUILD_FORMS_QML=ON`, since it +depends on the `MorphForms` QML module and the `morph_forms_module` +static library). + +```cmake +qt_add_executable(morph_forms_shell + main.cpp + FormsShellController.hpp FormsShellController.cpp + PageTreeModel.hpp PageTreeModel.cpp +) +qt_add_qml_module(morph_forms_shell + URI MorphFormsShell + VERSION 1.0 + QML_FILES + qml/Main.qml + qml/FormShell.qml + qml/FormPage.qml + qml/PageConfigDialog.qml + SOURCES + FormsShellController.hpp FormsShellController.cpp + PageTreeModel.hpp PageTreeModel.cpp +) +target_link_libraries(morph_forms_shell PRIVATE + morph_forms_moduleplugin Qt6::Quick) +``` \ No newline at end of file diff --git a/examples/forms/CMakeLists.txt b/examples/forms/CMakeLists.txt index 0a03591..ce0ce93 100644 --- a/examples/forms/CMakeLists.txt +++ b/examples/forms/CMakeLists.txt @@ -20,6 +20,10 @@ if(MORPH_BUILD_FORMS_QML) add_subdirectory(gui_qml) endif() +if(MORPH_BUILD_FORMS_QML_SHELL AND MORPH_BUILD_FORMS_QML) + add_subdirectory(gui_qml_shell) +endif() + # Demo-level tests: they drive the *shipped* artifacts (the emitted page's JS # math via node, and the REPL binary end to end). if(MORPH_BUILD_TESTS) diff --git a/examples/forms/gui_qml_shell/CMakeLists.txt b/examples/forms/gui_qml_shell/CMakeLists.txt new file mode 100644 index 0000000..55b645a --- /dev/null +++ b/examples/forms/gui_qml_shell/CMakeLists.txt @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Qt Quick shell with dynamic page loading. Built when +# -DMORPH_BUILD_FORMS_QML_SHELL=ON (requires MORPH_BUILD_FORMS_QML=ON). + +find_package(Qt6 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2) + +qt_standard_project_setup(REQUIRES 6.5) + +qt_add_executable(morph_forms_shell + main.cpp + FormsShellController.hpp FormsShellController.cpp + PageTreeModel.hpp PageTreeModel.cpp +) + +qt_add_qml_module(morph_forms_shell + URI MorphFormsShell + VERSION 1.0 + QML_FILES + qml/Main.qml + qml/FormShell.qml + qml/FormPage.qml + qml/PageConfigDialog.qml + SOURCES + FormsShellController.hpp FormsShellController.cpp + PageTreeModel.hpp PageTreeModel.cpp +) + +target_include_directories(morph_forms_shell PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/..) +target_link_libraries(morph_forms_shell PRIVATE + morph_forms_moduleplugin + morph::morph + Qt6::Quick + Qt6::QuickControls2 +) + +# Ensure the MorphForms QML module plugin is built before the shell links it. +add_dependencies(morph_forms_shell morph_forms_moduleplugin) + +target_compile_features(morph_forms_shell PUBLIC cxx_std_23) + +if(WIN32) + find_program(MORPH_WINDEPLOYQT_EXECUTABLE windeployqt + HINTS "${QT6_INSTALL_PREFIX}/bin" "${Qt6_DIR}/../../../bin" + ) + if(MORPH_WINDEPLOYQT_EXECUTABLE) + add_custom_command(TARGET morph_forms_shell POST_BUILD + COMMAND "${MORPH_WINDEPLOYQT_EXECUTABLE}" + --qmldir "${CMAKE_CURRENT_SOURCE_DIR}/qml" + $ + COMMENT "Deploying Qt runtime DLLs next to morph_forms_shell.exe" + ) + else() + message(WARNING "windeployqt not found; morph_forms_shell.exe will need " + "Qt's bin directory on PATH to run.") + endif() +endif() \ No newline at end of file diff --git a/examples/forms/gui_qml_shell/FormsShellController.cpp b/examples/forms/gui_qml_shell/FormsShellController.cpp new file mode 100644 index 0000000..4ef4253 --- /dev/null +++ b/examples/forms/gui_qml_shell/FormsShellController.cpp @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "FormsShellController.hpp" +#include "PageTreeModel.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +#include "lab_schemas.hpp" + +namespace { + +QString errorText(const std::exception_ptr& err) { + try { + if (err) + std::rethrow_exception(err); + } catch (const std::exception& exc) { + return QString::fromUtf8(exc.what()); + } catch (...) { + return QStringLiteral("unknown error"); + } + return {}; +} + +} // namespace + +FormsShellController::FormsShellController(QObject* parent) + : QObject{parent}, + _pageModel{new PageTreeModel(this)}, + _bridge{std::make_unique(_pool)}, + _handler{_bridge, &_gui} { + _configPath = defaultConfigPath(); + auto* dir = new QDir(QFileInfo(_configPath).absolutePath()); + if (!dir->exists()) + dir->mkpath(QStringLiteral(".")); + if (QFile::exists(_configPath)) + loadConfig(); + else + seedDefaultTree(); +} + +QAbstractItemModel* FormsShellController::pageModel() const { + return _pageModel; +} + +QString FormsShellController::schemasJson() const { + return QString::fromStdString(lab::schemasJson()); +} + +QString FormsShellController::configPath() const { + return _configPath; +} + +void FormsShellController::setConfigPath(const QString& path) { + if (_configPath == path) + return; + _configPath = path; + emit configPathChanged(); +} + +void FormsShellController::submitIfValid(const QString& actionType, const QString& bodyJson) { + auto const actionTypeStd = actionType.toStdString(); + _handler.executeJson(actionTypeStd, bodyJson.toStdString()) + .then([this, actionType](std::string resultJson) { + emit replyReceived(actionType, true, QString::fromStdString(resultJson)); + }) + .onError([this, actionType](const std::exception_ptr& err) { + emit replyReceived(actionType, false, errorText(err)); + }); +} + +void FormsShellController::fetchOptions(const QString& optionsAction) { + _handler.execute(lab::ListSamples{}) + .then([this, optionsAction](lab::SampleList list) { + std::string json; + if (glz::write_json(list, json)) { + emit optionsReceived(optionsAction, false, QStringLiteral("failed to encode options")); + return; + } + emit optionsReceived(optionsAction, true, QString::fromStdString(json)); + }) + .onError([this, optionsAction](const std::exception_ptr& err) { + emit optionsReceived(optionsAction, false, errorText(err)); + }); +} + +void FormsShellController::saveConfig() { + QFile file(_configPath); + if (!file.open(QIODevice::WriteOnly)) + return; + QJsonDocument doc(_pageModel->toJson()); + file.write(doc.toJson(QJsonDocument::Indented)); +} + +void FormsShellController::loadConfig() { + QFile file(_configPath); + if (!file.open(QIODevice::ReadOnly)) + return; + auto doc = QJsonDocument::fromJson(file.readAll()); + if (!doc.isArray()) + return; + _pageModel->fromJson(doc.array()); +} + +void FormsShellController::resetToDefaults() { + seedDefaultTree(); +} + +QString FormsShellController::defaultConfigPath() const { + auto base = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); + return base + QStringLiteral("/morph/morph-shell/pages.json"); +} + +void FormsShellController::seedDefaultTree() { + auto builtinFolder = _pageModel->addFolder({}, QStringLiteral("Built-in Forms")); + _pageModel->addPage(builtinFolder, + QStringLiteral("Compute Dry Density"), + QStringLiteral("builtin://ComputeDryDensity")); + _pageModel->addPage(builtinFolder, + QStringLiteral("Record Measurement"), + QStringLiteral("builtin://RecordMeasurement")); +} \ No newline at end of file diff --git a/examples/forms/gui_qml_shell/FormsShellController.hpp b/examples/forms/gui_qml_shell/FormsShellController.hpp new file mode 100644 index 0000000..292c70c --- /dev/null +++ b/examples/forms/gui_qml_shell/FormsShellController.hpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/// @file +/// QML-facing controller for the form shell. Owns the bridge stack, the +/// page tree model, and the persistent config. Registered as both a +/// QML_ELEMENT and a context property ("morphController") so that user- +/// loaded .qml pages can access form submission. + +#include +#include +#include +#include + +#include "PageTreeModel.hpp" + +#ifndef Q_MOC_RUN +#include +#include +#include + +#include "lab_model.hpp" +#endif + +class FormsShellController : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QAbstractItemModel* pageModel READ pageModel CONSTANT) + Q_PROPERTY(QString schemasJson READ schemasJson CONSTANT) + Q_PROPERTY(QString configPath READ configPath WRITE setConfigPath NOTIFY configPathChanged) + +public: + explicit FormsShellController(QObject* parent = nullptr); + + QAbstractItemModel* pageModel() const; + QString schemasJson() const; + QString configPath() const; + void setConfigPath(const QString& path); + + Q_INVOKABLE void submitIfValid(const QString& actionType, const QString& bodyJson); + Q_INVOKABLE void fetchOptions(const QString& optionsAction); + + Q_INVOKABLE void saveConfig(); + Q_INVOKABLE void loadConfig(); + Q_INVOKABLE void resetToDefaults(); + +signals: + void configPathChanged(); + void replyReceived(const QString& actionType, bool ok, const QString& payload); + void optionsReceived(const QString& optionsAction, bool ok, const QString& payload); + +private: + QString defaultConfigPath() const; + void seedDefaultTree(); + + PageTreeModel* _pageModel = nullptr; + QString _configPath; + + morph::exec::ThreadPoolExecutor _pool{2}; + morph::qt::QtExecutor _gui; + morph::bridge::Bridge _bridge; + morph::bridge::BridgeHandler _handler; +}; \ No newline at end of file diff --git a/examples/forms/gui_qml_shell/PageTreeModel.cpp b/examples/forms/gui_qml_shell/PageTreeModel.cpp new file mode 100644 index 0000000..8a7220d --- /dev/null +++ b/examples/forms/gui_qml_shell/PageTreeModel.cpp @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "PageTreeModel.hpp" + +#include +#include + +PageTreeModel::PageTreeModel(QObject* parent) + : QAbstractItemModel{parent} { + _root.isFolder = true; +} + +PageTreeModel::~PageTreeModel() = default; + +QModelIndex PageTreeModel::index(int row, int column, const QModelIndex& parentIdx) const { + if (!hasIndex(row, column, parentIdx)) + return {}; + auto* parent = parentIdx.isValid() ? nodeFromIndex(parentIdx) : const_cast(&_root); + if (row < parent->children.size()) + return createIndex(row, column, parent->children[row]); + return {}; +} + +QModelIndex PageTreeModel::parent(const QModelIndex& index) const { + if (!index.isValid()) + return {}; + auto* node = nodeFromIndex(index); + if (!node || node->parent == &_root || !node->parent) + return {}; + return indexFromNode(node->parent); +} + +int PageTreeModel::rowCount(const QModelIndex& parentIdx) const { + if (!parentIdx.isValid()) + return _root.children.size(); + auto* node = nodeFromIndex(parentIdx); + return node ? node->children.size() : 0; +} + +int PageTreeModel::columnCount(const QModelIndex&) const { + return 1; +} + +QVariant PageTreeModel::data(const QModelIndex& index, int role) const { + if (!index.isValid()) + return {}; + auto* node = nodeFromIndex(index); + if (!node) + return {}; + switch (role) { + case Qt::DisplayRole: + case NameRole: + return node->name; + case SourceRole: + return node->source; + case NodeTypeRole: + return node->isFolder ? QStringLiteral("folder") : QStringLiteral("page"); + case VisibleRole: + return node->visible; + } + return {}; +} + +QHash PageTreeModel::roleNames() const { + return { + {NameRole, "name"}, + {SourceRole, "source"}, + {NodeTypeRole, "nodeType"}, + {VisibleRole, "visible"}, + }; +} + +Qt::ItemFlags PageTreeModel::flags(const QModelIndex& index) const { + if (!index.isValid()) + return Qt::NoItemFlags; + return Qt::ItemIsEnabled | Qt::ItemIsSelectable; +} + +QModelIndex PageTreeModel::addFolder(const QModelIndex& parentIdx, const QString& name) { + auto* parent = parentIdx.isValid() ? nodeFromIndex(parentIdx) : &_root; + if (!parent) + return {}; + auto* child = appendChild(parent, name, {}, true); + return indexFromNode(child); +} + +QModelIndex PageTreeModel::addPage(const QModelIndex& parentIdx, const QString& name, const QString& source) { + auto* parent = parentIdx.isValid() ? nodeFromIndex(parentIdx) : &_root; + if (!parent) + return {}; + auto* child = appendChild(parent, name, source, false); + return indexFromNode(child); +} + +bool PageTreeModel::removeNode(const QModelIndex& index) { + if (!index.isValid()) + return false; + auto* node = nodeFromIndex(index); + if (!node || !node->parent) + return false; + auto* parent = node->parent; + int row = parent->children.indexOf(node); + if (row < 0) + return false; + beginRemoveRows(indexFromNode(parent), row, row); + parent->children.removeAt(row); + delete node; + endRemoveRows(); + emit treeChanged(); + return true; +} + +bool PageTreeModel::renameNode(const QModelIndex& index, const QString& name) { + if (!index.isValid()) + return false; + auto* node = nodeFromIndex(index); + if (!node) + return false; + node->name = name; + emit dataChanged(index, index, {NameRole, Qt::DisplayRole}); + emit treeChanged(); + return true; +} + +bool PageTreeModel::setNodeVisible(const QModelIndex& index, bool visible) { + if (!index.isValid()) + return false; + auto* node = nodeFromIndex(index); + if (!node) + return false; + node->visible = visible; + emit dataChanged(index, index, {VisibleRole}); + emit treeChanged(); + return true; +} + +bool PageTreeModel::moveNode(const QModelIndex& from, const QModelIndex& toParent, int toRow) { + if (!from.isValid()) + return false; + auto* node = nodeFromIndex(from); + if (!node || !node->parent) + return false; + auto* srcParent = node->parent; + auto* dstParent = toParent.isValid() ? nodeFromIndex(toParent) : &_root; + if (!dstParent) + return false; + int srcRow = srcParent->children.indexOf(node); + if (srcRow < 0) + return false; + if (srcParent == dstParent && toRow == srcRow) + return false; + if (toRow > srcRow) + --toRow; // adjust after removal + if (!beginMoveRows(indexFromNode(srcParent), srcRow, srcRow, indexFromNode(dstParent), toRow)) + return false; + srcParent->children.removeAt(srcRow); + if (toRow < 0 || toRow > dstParent->children.size()) + dstParent->children.append(node); + else + dstParent->children.insert(toRow, node); + node->parent = dstParent; + endMoveRows(); + emit treeChanged(); + return true; +} + +QJsonArray PageTreeModel::toJson() const { + QJsonArray arr; + for (auto* child : _root.children) { + QJsonObject obj; + obj["name"] = child->name; + obj["type"] = child->isFolder ? QStringLiteral("folder") : QStringLiteral("page"); + obj["visible"] = child->visible; + if (!child->isFolder) + obj["source"] = child->source; + if (child->isFolder && !child->children.isEmpty()) { + QJsonArray childArr; + for (auto* gc : child->children) { + QJsonObject go; + go["name"] = gc->name; + go["type"] = gc->isFolder ? QStringLiteral("folder") : QStringLiteral("page"); + go["visible"] = gc->visible; + if (!gc->isFolder) + go["source"] = gc->source; + childArr.append(go); + } + obj["children"] = childArr; + } + arr.append(obj); + } + return arr; +} + +void PageTreeModel::fromJson(const QJsonArray& json) { + beginResetModel(); + qDeleteAll(_root.children); + _root.children.clear(); + buildFromJson(json, &_root); + endResetModel(); + emit treeChanged(); +} + +bool PageTreeModel::isFolder(const QModelIndex& index) const { + if (!index.isValid()) + return false; + auto* node = nodeFromIndex(index); + return node && node->isFolder; +} + +QString PageTreeModel::nodeSource(const QModelIndex& index) const { + if (!index.isValid()) + return {}; + auto* node = nodeFromIndex(index); + return node ? node->source : QString{}; +} + +QModelIndex PageTreeModel::findPageBySource(const QString& source, const QModelIndex& parentIdx) const { + auto* parent = parentIdx.isValid() ? nodeFromIndex(parentIdx) : const_cast(&_root); + if (!parent) + return {}; + for (int i = 0; i < parent->children.size(); ++i) { + auto* child = parent->children[i]; + if (!child->isFolder && child->source == source) + return createIndex(i, 0, child); + if (child->isFolder) { + auto found = findPageBySource(source, indexFromNode(child)); + if (found.isValid()) + return found; + } + } + return {}; +} + +PageNode* PageTreeModel::nodeFromIndex(const QModelIndex& index) const { + if (!index.isValid()) + return nullptr; + return static_cast(index.internalPointer()); +} + +QModelIndex PageTreeModel::indexFromNode(PageNode* node) const { + if (!node || !node->parent) + return {}; + int row = node->parent->children.indexOf(node); + if (row < 0) + return {}; + return createIndex(row, 0, node); +} + +PageNode* PageTreeModel::appendChild(PageNode* parent, const QString& name, const QString& source, bool isFolder) { + auto* child = new PageNode; + child->name = name; + child->source = source; + child->isFolder = isFolder; + child->parent = parent; + int row = parent->children.size(); + beginInsertRows(indexFromNode(parent), row, row); + parent->children.append(child); + endInsertRows(); + emit treeChanged(); + return child; +} + +void PageTreeModel::buildFromJson(const QJsonArray& json, PageNode* parent) { + for (const auto& val : json) { + auto obj = val.toObject(); + auto* child = new PageNode; + child->name = obj["name"].toString(); + child->source = obj["source"].toString(); + child->isFolder = obj["type"].toString() == QStringLiteral("folder"); + child->visible = obj.value("visible").toBool(true); + child->parent = parent; + parent->children.append(child); + if (child->isFolder && obj.contains("children")) + buildFromJson(obj["children"].toArray(), child); + } +} \ No newline at end of file diff --git a/examples/forms/gui_qml_shell/PageTreeModel.hpp b/examples/forms/gui_qml_shell/PageTreeModel.hpp new file mode 100644 index 0000000..e150ab5 --- /dev/null +++ b/examples/forms/gui_qml_shell/PageTreeModel.hpp @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/// @file +/// QAbstractItemModel that represents the navigation tree for the form shell. +/// Nodes are folders (internal) or pages (leaves). The tree is backed by a +/// JSON file on disk for persistence. + +#include +#include +#include +#include +#include +#include + +struct PageNode { + QString name; + QString source; // "builtin://ActionType" or file:// URL + bool isFolder = false; + bool visible = true; + PageNode* parent = nullptr; + QVector children; + + ~PageNode() { qDeleteAll(children); } +}; + +class PageTreeModel : public QAbstractItemModel { + Q_OBJECT + +public: + enum Roles { + NameRole = Qt::UserRole + 1, + SourceRole, + NodeTypeRole, + VisibleRole, + }; + + explicit PageTreeModel(QObject* parent = nullptr); + ~PageTreeModel() override; + + // QAbstractItemModel interface + QModelIndex index(int row, int column, const QModelIndex& parent = {}) const override; + QModelIndex parent(const QModelIndex& index) const override; + int rowCount(const QModelIndex& parent = {}) const override; + int columnCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + Qt::ItemFlags flags(const QModelIndex& index) const override; + + // Tree mutation + Q_INVOKABLE QModelIndex addFolder(const QModelIndex& parent, const QString& name); + Q_INVOKABLE QModelIndex addPage(const QModelIndex& parent, const QString& name, const QString& source); + Q_INVOKABLE bool removeNode(const QModelIndex& index); + Q_INVOKABLE bool renameNode(const QModelIndex& index, const QString& name); + Q_INVOKABLE bool setNodeVisible(const QModelIndex& index, bool visible); + Q_INVOKABLE bool moveNode(const QModelIndex& from, const QModelIndex& toParent, int toRow); + + // Serialization + Q_INVOKABLE QJsonArray toJson() const; + Q_INVOKABLE void fromJson(const QJsonArray& json); + + // Query + Q_INVOKABLE bool isFolder(const QModelIndex& index) const; + Q_INVOKABLE QString nodeSource(const QModelIndex& index) const; + + /// @brief Returns the index of the first page node matching @p source, + /// or an invalid index if not found. + QModelIndex findPageBySource(const QString& source, const QModelIndex& parent = {}) const; + +signals: + void treeChanged(); + +private: + PageNode* nodeFromIndex(const QModelIndex& index) const; + QModelIndex indexFromNode(PageNode* node) const; + PageNode* appendChild(PageNode* parent, const QString& name, const QString& source, bool isFolder); + void buildFromJson(const QJsonArray& json, PageNode* parent); + + PageNode _root; +}; \ No newline at end of file diff --git a/examples/forms/gui_qml_shell/main.cpp b/examples/forms/gui_qml_shell/main.cpp new file mode 100644 index 0000000..b96f230 --- /dev/null +++ b/examples/forms/gui_qml_shell/main.cpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include + +#include "FormsShellController.hpp" + +int main(int argc, char** argv) { + QCoreApplication::setOrganizationName(QStringLiteral("morph")); + QCoreApplication::setApplicationName(QStringLiteral("morph-shell")); + + QGuiApplication app{argc, argv}; + QQmlApplicationEngine engine; + + QObject::connect(&engine, &QQmlApplicationEngine::warnings, + [](const QList& warnings) { + for (const auto& w : warnings) + qWarning("%s", qPrintable(w.toString())); + }); + + FormsShellController shellController; + engine.rootContext()->setContextProperty(QStringLiteral("morphController"), &shellController); + + engine.loadFromModule(QStringLiteral("MorphFormsShell"), QStringLiteral("Main")); + if (engine.rootObjects().isEmpty()) { + qWarning("Failed to load MorphFormsShell module"); + return 1; + } + + return app.exec(); +} \ No newline at end of file diff --git a/examples/forms/gui_qml_shell/qml/FormPage.qml b/examples/forms/gui_qml_shell/qml/FormPage.qml new file mode 100644 index 0000000..8d31d6d --- /dev/null +++ b/examples/forms/gui_qml_shell/qml/FormPage.qml @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Wraps a DynamicForm (from the MorphForms module) as a full-page component +// inside a ScrollView. Used by the shell for built-in schema pages. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MorphForms + +ScrollView { + id: formPage + clip: true + contentWidth: availableWidth + + property string actionType: "" + property var schema: null + property var controller: null + + ColumnLayout { + width: formPage.width + spacing: 0 + + Label { + Layout.fillWidth: true + Layout.margins: 24 + Layout.bottomMargin: 8 + text: formPage.actionType + font.pixelSize: 24 + font.weight: Font.Bold + color: "#222222" + } + + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: 24 + Layout.rightMargin: 24 + Layout.bottomMargin: 12 + height: 1 + color: "#DDDDDD" + } + + DynamicForm { + Layout.fillWidth: true + Layout.leftMargin: 24 + Layout.rightMargin: 24 + Layout.bottomMargin: 24 + actionType: formPage.actionType + schema: formPage.schema + controller: formPage.controller + } + } +} \ No newline at end of file diff --git a/examples/forms/gui_qml_shell/qml/FormShell.qml b/examples/forms/gui_qml_shell/qml/FormShell.qml new file mode 100644 index 0000000..adde248 --- /dev/null +++ b/examples/forms/gui_qml_shell/qml/FormShell.qml @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Shell layout: tree sidebar on the left, dynamic page content on the right. +// The sidebar lists all visible pages from the PageTreeModel; clicking a leaf +// loads it into the content area. Built-in schema pages are wrapped in +// FormPage, user .qml files are loaded directly via Loader. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs +import MorphForms + +RowLayout { + id: shell + spacing: 0 + + required property var controller + readonly property var pageModel: controller.pageModel + property var schemas: ({}) + + // Cache schemas JSON once + Component.onCompleted: { + try { schemas = JSON.parse(controller.schemasJson) } catch (e) {} + } + + // Keep the tree model in sync when config is reloaded + Connections { + target: controller.pageModel + function onTreeChanged() { + // Trigger content re-evaluation if current selection is invalidated + } + } + + // ── Sidebar ───────────────────────────────────────────────────────────── + Rectangle { + Layout.preferredWidth: 280 + Layout.fillHeight: true + color: "#1E1E1E" + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Header + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 52 + color: "#2A2A2A" + + Label { + anchors.left: parent.left + anchors.leftMargin: 16 + anchors.verticalCenter: parent.verticalCenter + text: "morph shell" + color: "#FFFFFF" + font.pixelSize: 16 + font.weight: Font.Bold + } + } + + // Toolbar: add page, manage pages + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 8 + Layout.rightMargin: 8 + Layout.topMargin: 6 + Layout.bottomMargin: 6 + spacing: 4 + + Button { + text: "+" + flat: true + implicitWidth: 32 + implicitHeight: 28 + onClicked: addPageDialog.open() + } + + Label { + Layout.fillWidth: true + text: "Pages" + color: "#999999" + font.pixelSize: 11 + font.capitalization: Font.AllUppercase + } + + Button { + text: "⚙" + flat: true + implicitWidth: 32 + implicitHeight: 28 + onClicked: configDialog.open() + } + } + + // Tree view + TreeView { + id: treeView + Layout.fillWidth: true + Layout.fillHeight: true + model: controller.pageModel + clip: true + selectionMode: TreeView.SelectionMode.SingleSelection + + delegate: TreeViewDelegate { + id: delegate + implicitHeight: 36 + indentation: 16 + + contentItem: Item { + implicitHeight: delegate.implicitHeight + + Label { + anchors.left: parent.left + anchors.leftMargin: 4 + anchors.verticalCenter: parent.verticalCenter + text: model.name ?? "" + color: delegate.selected ? "#FFFFFF" : "#CCCCCC" + font.pixelSize: 13 + font.weight: delegate.selected ? Font.DemiBold : Font.Normal + elide: Text.ElideRight + } + } + + background: Rectangle { + color: delegate.selected ? "#3A3A3A" + : (delegate.row === undefined ? "transparent" + : treeView.hoveredRow === delegate.row ? "#2A2A2A" + : "transparent") + } + + TapHandler { + onTapped: { + var idx = model.modelIndex + if (!idx.valid) + return + treeView.currentIndex = idx + if (!controller.pageModel.isFolder(idx)) { + treeView.forceActiveFocus() + contentLoader.loadPage(idx) + } + } + } + } + } + + // Footer + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: "#333333" + } + } + } + + // ── Content area ──────────────────────────────────────────────────────── + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: "#F5F5F5" + + Loader { + id: contentLoader + anchors.fill: parent + asynchronous: true + + property var currentSource: "" + property var currentActionType: "" + + function loadPage(modelIndex) { + if (!modelIndex.valid) + return + var src = controller.pageModel.nodeSource(modelIndex) + if (!src) + return + + if (src.startsWith("builtin://")) { + var actionType = src.substring("builtin://".length) + currentActionType = actionType + currentSource = "" + + var comp = Qt.createComponent("FormPage.qml") + if (comp.status === Component.Ready || comp.status === Component.Loading) { + sourceComponent = comp + if (comp.status === Component.Ready) + applyFormPageProps() + else + comp.statusChanged.connect(applyFormPageProps) + } + } else { + currentSource = src + currentActionType = "" + sourceComponent = undefined + source = src + } + } + + function applyFormPageProps() { + if (item) { + item.actionType = contentLoader.currentActionType + var schema = shell.schemas[contentLoader.currentActionType] + item.schema = schema !== undefined ? schema : null + item.controller = shell.controller + } + } + } + } + + // ── Dialogs ───────────────────────────────────────────────────────────── + + FileDialog { + id: addPageDialog + title: "Add QML page" + nameFilters: ["QML files (*.qml)", "All files (*)"] + onAccepted: { + var treeIdx = treeView.currentIndex.valid ? treeView.currentIndex : controller.pageModel.index(0, 0) + var parentIdx = controller.pageModel.isFolder(treeIdx) ? treeIdx : undefined + var fileName = selectedFile.toString().split("/").pop() + if (fileName.endsWith(".qml")) + fileName = fileName.slice(0, -4) + controller.pageModel.addPage(parentIdx, fileName, selectedFile.toString()) + controller.saveConfig() + } + } + + PageConfigDialog { + id: configDialog + controller: shell.controller + } +} \ No newline at end of file diff --git a/examples/forms/gui_qml_shell/qml/Main.qml b/examples/forms/gui_qml_shell/qml/Main.qml new file mode 100644 index 0000000..3d34b19 --- /dev/null +++ b/examples/forms/gui_qml_shell/qml/Main.qml @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MorphFormsShell + +ApplicationWindow { + id: root + width: 1160 + height: 760 + visible: true + title: "morph shell" + + FormShell { + anchors.fill: parent + controller: morphController + } +} \ No newline at end of file diff --git a/examples/forms/gui_qml_shell/qml/PageConfigDialog.qml b/examples/forms/gui_qml_shell/qml/PageConfigDialog.qml new file mode 100644 index 0000000..20765df --- /dev/null +++ b/examples/forms/gui_qml_shell/qml/PageConfigDialog.qml @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Modal dialog for managing the page tree: add folders, add/remove pages, +// rename nodes, toggle visibility. The tree is committed to disk on Save. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs + +Dialog { + id: dialog + title: "Manage Pages" + modal: true + standardButtons: Dialog.Save | Dialog.Cancel + width: 520 + height: 480 + + required property var controller + readonly property var pageModel: controller.pageModel + + onAccepted: controller.saveConfig() + onRejected: controller.loadConfig() // revert changes + + // ── Layout ────────────────────────────────────────────────────────────── + contentItem: ColumnLayout { + RowLayout { + Layout.fillWidth: true + spacing: 4 + + Action { + id: addFolderAction + text: "Folder" + icon.name: "folder-new" + onTriggered: { + var sel = treeView.currentIndex + var parentIdx = sel.valid && pageModel.isFolder(sel) ? sel : undefined + pageModel.addFolder(parentIdx, "New Folder") + } + } + + Action { + id: addPageAction + text: "Page" + icon.name: "document-new" + onTriggered: fileDialog.open() + } + + Item { Layout.fillWidth: true } + +Action { + id: removeAction + text: "Remove" + icon.name: "edit-delete" + enabled: treeView.currentIndex ? treeView.currentIndex.valid : false + onTriggered: { + if (treeView.currentIndex && treeView.currentIndex.valid) + pageModel.removeNode(treeView.currentIndex) + } + } + } + + // Tree view + TreeView { + id: treeView + Layout.fillWidth: true + Layout.fillHeight: true + model: pageModel + clip: true + selectionMode: TreeView.SelectionMode.SingleSelection + + delegate: TreeViewDelegate { + id: delegate + implicitHeight: 32 + indentation: 16 + + contentItem: RowLayout { + spacing: 6 + + // Visibility toggle + CheckBox { + id: visCheck + checked: model.visible !== false + implicitWidth: 18 + implicitHeight: 18 + onToggled: { + var idx = pageModel.index(delegate.row, 0, treeView.parent(delegate.row)) + pageModel.setNodeVisible(idx, checked) + } + } + + // Name label + Label { + Layout.fillWidth: true + text: model.name ?? "" + color: delegate.selected ? "#FFFFFF" : "#222222" + font.pixelSize: 13 + font.weight: model.nodeType === "folder" ? Font.DemiBold : Font.Normal + elide: Text.ElideRight + } + + // Type badge + Label { + text: model.nodeType === "folder" ? "📁" : "📄" + font.pixelSize: 12 + opacity: 0.5 + } + } + + background: Rectangle { + color: delegate.selected ? "#3A6EA5" : "transparent" + } + + // Context menu + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: { + treeView.forceActiveFocus() + var idx = pageModel.index(delegate.row, 0, treeView.parent(delegate.row)) + treeView.currentIndex = idx + contextMenu.popup() + } + } + } + } + + Menu { + id: contextMenu + MenuItem { + text: "Rename" + onTriggered: renameDialog.open() + } + MenuItem { + text: "Remove" + onTriggered: { + if (treeView.currentIndex && treeView.currentIndex.valid) + pageModel.removeNode(treeView.currentIndex) + } + } + } + + // Rename dialog + Dialog { + id: renameDialog + title: "Rename" + modal: true + standardButtons: Dialog.Ok | Dialog.Cancel + width: 320 + + property string oldName: treeView.currentIndex && treeView.currentIndex.valid + ? pageModel.data(treeView.currentIndex, 257) : "" // NameRole + + contentItem: ColumnLayout { + spacing: 8 + Label { text: "New name:" } + TextField { + id: renameField + Layout.fillWidth: true + text: renameDialog.oldName + onAccepted: renameDialog.accept() + } + } + + onAccepted: { + if (renameField.text.trim() !== "" && treeView.currentIndex.valid) + pageModel.renameNode(treeView.currentIndex, renameField.text.trim()) + } + } + } + + FileDialog { + id: fileDialog + title: "Select QML page" + nameFilters: ["QML files (*.qml)", "All files (*)"] + onAccepted: { + var sel = treeView.currentIndex + var parentIdx = sel.valid && pageModel.isFolder(sel) ? sel : undefined + var fileName = selectedFile.toString().split("/").pop() + if (fileName.endsWith(".qml")) + fileName = fileName.slice(0, -4) + pageModel.addPage(parentIdx, fileName, selectedFile.toString()) + } + } +} \ No newline at end of file