Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
177 changes: 177 additions & 0 deletions docs/superpowers/2026-07-12-qml-shell.md
Original file line number Diff line number Diff line change
@@ -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<LabModel> — 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:
`<AppConfigLocation>/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)
```
4 changes: 4 additions & 0 deletions examples/forms/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions examples/forms/gui_qml_shell/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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"
$<TARGET_FILE:morph_forms_shell>
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()
Loading
Loading