Files
logos-tutorial/tutorial-qml-ui-app.md

777 lines
26 KiB
Markdown
Raw Permalink Normal View History

# Tutorial Part 2: Building a QML UI App for Your Logos Module
This is Part 2 of the Logos module tutorial series. In [Part 1](tutorial-wrapping-c-library.md), you wrapped a C library (`libcalc`) as a Logos module. Now you'll build a **QML user interface** that calls that module from inside the Logos desktop app.
**What you'll build:** A `calc_ui` QML app with input fields, buttons, and a result display that calls `calc_module` methods (add, multiply, factorial, fibonacci) through the Logos bridge.
**What you'll learn:**
2026-03-06 16:45:49 +00:00
- How QML UI plugins work in the Logos platform
- The `logos.callModule()` bridge that connects QML to core modules
- The project structure and metadata for a QML plugin
- How to build, install, and run your UI inside `logos-basecamp`
**Prerequisites:**
2026-03-06 16:45:49 +00:00
- Completed [Part 1](tutorial-wrapping-c-library.md) — you have a working `calc_module`
- Nix with flakes enabled (same as Part 1)
- Basic familiarity with QML (Qt's declarative UI language)
---
## How QML UI Plugins Work
Before writing code, let's understand the architecture:
```
+--------------------+ logos.callModule() +--------------------+
| calc_ui | -----------------------------> | calc_module |
| (QML, sandboxed)| IPC (Qt Remote Objects) | (C++ plugin) |
| Main.qml | | wraps libcalc.so |
+--------------------+ +--------------------+
^ ^
| loaded by | loaded by
v v
+---------------------------------------------------------------+
| logos-basecamp |
| QML sandbox engine | logos_host process |
+---------------------------------------------------------------+
```
Key points:
1. **QML plugins are pure QML** — no C++ compilation needed. You write `.qml` files and a `metadata.json`.
2. **QML plugins are sandboxed** — no network access, no filesystem access outside the module directory.
3. **The `logos` bridge** is injected by the host. You call core modules with `logos.callModule("module_name", "method", [args])`.
4. **The entry point** is always `Main.qml`. The host loads this file into a sandboxed QML engine.
---
## Step 1: Create the Project
The project structure is minimal:
```
logos-calc-ui/
├── Main.qml # The UI (your only code file)
├── metadata.json # Plugin metadata
├── flake.nix # Nix build config
└── icons/
└── calc.png # Tab icon (optional)
```
### 1.1 Create the directory
```bash
mkdir logos-calc-ui && cd logos-calc-ui
mkdir icons
```
### 1.2 Add an icon (optional)
Place a PNG icon at `icons/calc.png`. This appears in the `logos-basecamp` sidebar. If you don't have one, the app will use a default icon.
---
## Step 2: Write `metadata.json`
This tells `logos-basecamp` what your plugin is and how to load it.
```json
{
"name": "calc_ui",
"version": "1.0.0",
"description": "Calculator UI - QML frontend for the calc_module",
"author": "",
"type": "ui_qml",
"pluginType": "qml",
"main": "Main.qml",
"dependencies": ["calc_module"],
"category": "tools",
"capabilities": [],
"icon": "icons/calc.png"
}
```
**Key fields explained:**
2026-03-06 16:45:49 +00:00
| Field | What it does |
| -------------- | ------------------------------------------------------------------- |
| `type` | Must be `"ui_qml"` for QML UI plugins |
| `pluginType` | Must be `"qml"` |
| `main` | Entry point QML file — always `"Main.qml"` |
| `dependencies` | Core modules this UI needs. `logos-basecamp` loads these before your UI. |
2026-03-06 16:45:49 +00:00
| `category` | Groups plugins in the sidebar (e.g., `"tools"`, `"misc"`) |
| `icon` | Path to the sidebar icon, relative to the plugin directory |
**Compare with `calc_module`'s metadata** (from Part 1):
```json
{
"name": "calc_module",
"type": "core",
"main": "calc_module_plugin",
...
}
```
The core module has `"type": "core"` and `"main"` points to a compiled plugin binary. The QML UI has `"type": "ui_qml"` and `"main"` points to a `.qml` file. Different types, same metadata format.
---
## Step 3: Write `Main.qml`
2026-03-06 16:54:20 +00:00
This is the entire UI. The complete file is at section 3.3
### 3.1 Scaffold
Create `Main.qml`:
```qml
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
// State
property string result: ""
property string errorText: ""
ColumnLayout {
anchors.fill: parent
anchors.margins: 24
spacing: 16
// Title
Text {
text: "Logos Calculator"
font.pixelSize: 20
font.weight: Font.DemiBold
color: "#1f2328"
Layout.alignment: Qt.AlignHCenter
}
Text {
text: "QML frontend for the calc_module (libcalc C library)"
font.pixelSize: 13
color: "#57606a"
Layout.alignment: Qt.AlignHCenter
}
// ... sections go here ...
// Push everything up
Item { Layout.fillHeight: true }
}
// ... helper functions go here ...
}
```
The root `Item` with a `ColumnLayout` is the standard pattern. Two properties track the result and any error.
### 3.2 The Logos bridge function
This is the most important part — the function that calls your core module. Add this at the bottom of the `Item`, after the `ColumnLayout`:
```qml
function callModule(method, args) {
root.errorText = ""
root.result = ""
// The logos object is injected by the host at runtime.
// It won't exist if you open Main.qml in a standalone QML viewer.
if (typeof logos === "undefined" || !logos.callModule) {
root.errorText = "Logos bridge not available (run inside logos-basecamp)"
return
}
var res = logos.callModule("calc_module", method, args)
root.result = String(res)
}
```
**How `logos.callModule()` works:**
```
logos.callModule(moduleName, methodName, argsArray)
│ │ │
│ │ └── JavaScript array of arguments
│ └── Method name on the module (e.g., "add")
└── Module name from metadata.json (e.g., "calc_module")
```
The call is synchronous from QML's perspective. Under the hood, `logos-basecamp` routes it via IPC to the `logos_host` process running `calc_module`, which calls `CalcModulePlugin::add()`, which calls `calc_add()` from libcalc. The result comes back through the same chain.
2026-03-06 16:54:20 +00:00
### 3.3 The complete file
Here is the complete `Main.qml` with all sections assembled:
```qml
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
// State
property string result: ""
property string errorText: ""
ColumnLayout {
anchors.fill: parent
anchors.margins: 24
spacing: 16
// ── Title ──────────────────────────────────────────────
Text {
text: "Logos Calculator"
font.pixelSize: 20
font.weight: Font.DemiBold
color: "#1f2328"
Layout.alignment: Qt.AlignHCenter
}
Text {
text: "QML frontend for the calc_module (libcalc C library)"
font.pixelSize: 13
color: "#57606a"
Layout.alignment: Qt.AlignHCenter
}
// ── Two-operand section ────────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: twoOpColumn.implicitHeight + 32
color: "#f6f8fa"
radius: 8
border.color: "#d1d9e0"
border.width: 1
ColumnLayout {
id: twoOpColumn
anchors.fill: parent
anchors.margins: 16
spacing: 12
Text {
text: "Two-operand operations"
font.pixelSize: 14
font.weight: Font.DemiBold
color: "#1f2328"
}
RowLayout {
spacing: 12
Layout.fillWidth: true
TextField {
id: inputA
placeholderText: "a"
Layout.preferredWidth: 100
validator: IntValidator {}
}
TextField {
id: inputB
placeholderText: "b"
Layout.preferredWidth: 100
validator: IntValidator {}
}
Button {
text: "Add"
onClicked: callTwoOp("add", inputA.text, inputB.text)
background: Rectangle {
implicitWidth: 80
implicitHeight: 36
color: parent.pressed ? "#1a7f37" : "#238636"
radius: 6
}
contentItem: Text {
text: parent.text
color: "#ffffff"
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Button {
text: "Multiply"
onClicked: callTwoOp("multiply", inputA.text, inputB.text)
background: Rectangle {
implicitWidth: 80
implicitHeight: 36
color: parent.pressed ? "#1a7f37" : "#238636"
radius: 6
}
contentItem: Text {
text: parent.text
color: "#ffffff"
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
}
// ── Single-operand section ─────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: oneOpColumn.implicitHeight + 32
color: "#f6f8fa"
radius: 8
border.color: "#d1d9e0"
border.width: 1
ColumnLayout {
id: oneOpColumn
anchors.fill: parent
anchors.margins: 16
spacing: 12
Text {
text: "Single-operand operations"
font.pixelSize: 14
font.weight: Font.DemiBold
color: "#1f2328"
}
RowLayout {
spacing: 12
Layout.fillWidth: true
TextField {
id: inputN
placeholderText: "n"
Layout.preferredWidth: 100
validator: IntValidator { bottom: 0 }
}
Button {
text: "Factorial"
onClicked: callOneOp("factorial", inputN.text)
background: Rectangle {
implicitWidth: 80
implicitHeight: 36
color: parent.pressed ? "#0a58ca" : "#0969da"
radius: 6
}
contentItem: Text {
text: parent.text
color: "#ffffff"
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Button {
text: "Fibonacci"
onClicked: callOneOp("fibonacci", inputN.text)
background: Rectangle {
implicitWidth: 80
implicitHeight: 36
color: parent.pressed ? "#0a58ca" : "#0969da"
radius: 6
}
contentItem: Text {
text: parent.text
color: "#ffffff"
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
}
// ── Info section ───────────────────────────────────────
Button {
text: "Get libcalc version"
onClicked: callNoArg("libVersion")
background: Rectangle {
implicitWidth: 160
implicitHeight: 36
color: parent.pressed ? "#32383f" : "#24292f"
radius: 6
}
contentItem: Text {
text: parent.text
color: "#ffffff"
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
// ── Result display ─────────────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 64
color: root.errorText.length > 0 ? "#fff1f0" : "#dafbe1"
radius: 8
border.color: root.errorText.length > 0 ? "#ffcdd2" : "#adf0b9"
border.width: 1
ColumnLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 4
Text {
text: root.errorText.length > 0 ? "Error" : "Result"
font.pixelSize: 12
font.weight: Font.DemiBold
color: root.errorText.length > 0 ? "#cf222e" : "#116329"
}
Text {
text: root.errorText.length > 0 ? root.errorText
: (root.result.length > 0 ? root.result
: "Press a button above")
font.pixelSize: 16
font.weight: Font.Medium
color: root.errorText.length > 0 ? "#cf222e" : "#1f2328"
Layout.fillWidth: true
elide: Text.ElideRight
}
}
}
// Push everything up
Item { Layout.fillHeight: true }
}
// ── Helper functions ───────────────────────────────────────
function callModule(method, args) {
root.errorText = ""
root.result = ""
if (typeof logos === "undefined" || !logos.callModule) {
root.errorText = "Logos bridge not available (run inside logos-basecamp)"
return
}
var res = logos.callModule("calc_module", method, args)
root.result = String(res)
}
function callTwoOp(method, a, b) {
if (a === "" || b === "") {
root.errorText = "Enter values for both a and b"
return
}
callModule(method, [parseInt(a), parseInt(b)])
}
function callOneOp(method, n) {
if (n === "") {
root.errorText = "Enter a value for n"
return
}
callModule(method, [parseInt(n)])
}
function callNoArg(method) {
callModule(method, [])
}
}
```
---
## Step 4: Write `flake.nix`
QML plugins don't need compilation — the Nix build just copies files to the output. But we still use a flake so the build process is consistent with core modules and the plugin can be consumed by other Nix expressions.
```nix
{
description = "Calculator QML UI Plugin for Logos - frontend for calc_module";
inputs = {
logos-cpp-sdk.url = "github:logos-co/logos-cpp-sdk";
nixpkgs.follows = "logos-cpp-sdk/nixpkgs";
};
outputs = { self, nixpkgs, logos-cpp-sdk }:
let
systems = [ "aarch64-darwin" "x86_64-darwin" "aarch64-linux" "x86_64-linux" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f {
pkgs = import nixpkgs { inherit system; };
});
in
{
packages = forAllSystems ({ pkgs }: let
plugin = pkgs.stdenv.mkDerivation {
pname = "logos-calc-ui-plugin";
version = "1.0.0";
src = ./.;
dontUnpack = false;
phases = [ "unpackPhase" "installPhase" ];
installPhase = ''
runHook preInstall
dest="$out/lib"
mkdir -p "$dest/icons"
cp $src/Main.qml "$dest/Main.qml"
cp $src/metadata.json "$dest/metadata.json"
# Copy icon if present
if [ -f "$src/icons/calc.png" ]; then
cp $src/icons/calc.png "$dest/icons/calc.png"
fi
runHook postInstall
'';
meta = with pkgs.lib; {
description = "Calculator QML UI Plugin for Logos";
platforms = platforms.unix;
};
};
in {
default = plugin;
lib = plugin;
});
};
}
```
**Compared to a core module's `flake.nix`** (from Part 1), this is much simpler:
2026-03-06 16:45:49 +00:00
| | Core module (`calc_module`) | QML UI (`calc_ui`) |
| ------------ | ---------------------------------------- | --------------------------- |
| Builder | `logos-module-builder.lib.mkLogosModule` | Plain `stdenv.mkDerivation` |
| Compilation | CMake → C++ → `.so` plugin | No compilation — file copy |
| Dependencies | Qt, Logos SDK, CMake, C compiler | None |
| Build time | Minutes (first build) | Seconds |
---
## Step 5: Build the Plugin
### 5.1 Initialize the Git repo
Nix flakes require a git repository:
```bash
cd logos-calc-ui
git init
git add -A
git commit -m "Initial commit"
```
### 5.2 Build with Nix
```bash
nix build
```
This completes in seconds since there's no compilation — it just copies files.
### 5.3 Inspect the output
```bash
ls -la result/lib/
```
```
Main.qml
metadata.json
icons/
```
That's it. A QML plugin is just its source files packaged for installation.
---
## Step 6: Run in `logos-basecamp`
### 6.1 Build `logos-basecamp`
```bash
nix build 'github:logos-co/logos-basecamp#app' --out-link ./logos-basecamp
```
### 6.2 Set up the modules directory
2026-03-07 15:04:37 +00:00
You need both the core module (from Part 1) and the QML UI plugin. Use the LGX bundler and package manager to install them:
```bash
2026-03-07 15:04:37 +00:00
# Bundle and install the core module (from Part 1)
cd ../logos-calc-module
nix bundle --bundler 'github:logos-co/nix-bundle-lgx' '.#lib' -o lgx-result
cd ../logos-calc-ui
2026-03-07 15:04:37 +00:00
nix build 'github:logos-co/logos-package-manager-module#cli' --out-link ./pm
mkdir -p modules
./pm/bin/lgpm --modules-dir ./modules install --file ../logos-calc-module/lgx-result/*.lgx
2026-03-07 15:04:37 +00:00
# Bundle and install the QML UI plugin
nix bundle --bundler 'github:logos-co/nix-bundle-lgx' '.' -o lgx-result
mkdir -p ui-plugins
./pm/bin/lgpm --modules-dir ./ui-plugins install --file lgx-result/*.lgx
```
### 6.3 Launch
```bash
./logos-basecamp/bin/logos-basecamp \
--modules-dir ./modules \
--ui-plugins-dir ./ui-plugins
```
You should see:
2026-03-06 16:45:49 +00:00
1. The `logos-basecamp` window opens with a sidebar
2. "Calculator UI" appears as a tab (with your icon if you provided one)
3. Click the tab to see your QML interface
4. Enter numbers and click **Add**, **Multiply**, **Factorial**, or **Fibonacci**
5. The result appears in the green result box
### 6.4 What happens when you click "Add"
Here's the full chain when you enter `3` and `5` and click **Add**:
```
1. QML: Button.onClicked → callTwoOp("add", "3", "5")
2. QML: callTwoOp() → callModule("add", [3, 5])
3. QML: logos.callModule("calc_module", "add", [3, 5])
4. logos-basecamp: Routes call via IPC to logos_host process
5. logos_host: QMetaObject::invokeMethod(plugin, "add", 3, 5)
6. C++: CalcModulePlugin::add(3, 5) → calc_add(3, 5)
7. C: Returns 8
8. Back through the chain → QML: root.result = "8"
9. QML: Result box displays "8"
```
---
## Step 7: Development Workflow
### 7.1 Live reloading
For rapid iteration, use development mode. This watches your QML source files and reloads on change:
```bash
# Point QML_UI at your source directory
QML_UI=$(pwd) ./logos-basecamp/bin/logos-basecamp \
--modules-dir ./modules \
--ui-plugins-dir ./ui-plugins
```
Edit `Main.qml`, save, and the UI updates without rebuilding.
### 7.2 Debugging
Since QML plugins are sandboxed, you can't use `console.log()` to write to the terminal in production. But in development mode, `console.log()` output appears in the terminal where you launched `logos-basecamp`.
Add debug logging to your bridge function:
```qml
function callModule(method, args) {
console.log("callModule:", method, JSON.stringify(args))
// ...
var res = logos.callModule("calc_module", method, args)
console.log("result:", res)
root.result = String(res)
}
```
### 7.3 Testing without `logos-basecamp`
You can open `Main.qml` in any QML viewer (e.g., `qml` from Qt) to test the layout. The `logos` bridge won't be available, so clicking buttons will show "Logos bridge not available" — but you can verify the layout and styling work correctly.
```bash
# If you have Qt installed
qml Main.qml
```
---
## Step 8: Package for Distribution (Optional)
2026-03-07 15:04:37 +00:00
The LGX packages created in Step 6.2 are **local** packages — they work on the machine that built them but contain `/nix/store` references. To create **portable** packages for distribution to other machines, use the `#portable` bundler:
```bash
2026-03-07 15:04:37 +00:00
# Portable core module
cd ../logos-calc-module
nix bundle --bundler 'github:logos-co/nix-bundle-lgx#portable' '.#lib' -o lgx-portable
# Portable QML UI plugin
cd ../logos-calc-ui
nix bundle --bundler 'github:logos-co/nix-bundle-lgx#portable' '.' -o lgx-portable
```
2026-03-07 15:04:37 +00:00
Portable LGX packages are fully self-contained and can be installed on any machine with the Logos Package Manager:
```bash
nix build 'github:logos-co/logos-package-manager-module#cli' --out-link ./pm
2026-03-07 15:04:37 +00:00
./pm/bin/lgpm --modules-dir ./modules install --file calc_module.lgx
./pm/bin/lgpm --modules-dir ./ui-plugins install --file calc_ui.lgx
```
> **Local vs portable:** Local builds of `logos-basecamp` (via `nix build '.#app'`) expect **local** `.lgx` packages. Portable builds (via `nix build '.#bin-bundle-dir'`, `.#bin-appimage`, or `.#bin-macos-app`) expect **portable** `.lgx` packages. See the [logos-basecamp README](https://github.com/logos-co/logos-basecamp/blob/master/README.md) for details.
2026-03-07 15:04:37 +00:00
---
## Recap: Core Module vs. QML UI Plugin
2026-03-06 16:45:49 +00:00
| | Core Module (Part 1) | QML UI Plugin (Part 2) |
| ----------------------- | --------------------------------------------- | --------------------------------- |
| **Language** | C++ (wrapping C) | QML (JavaScript + declarative UI) |
| **Files** | `.cpp`, `.h`, `CMakeLists.txt`, `module.yaml` | `Main.qml` only |
| **Compilation** | Yes (CMake → shared library) | No (file copy) |
| **metadata `type`** | `"core"` | `"ui_qml"` |
| **metadata `main`** | `"calc_module_plugin"` (binary) | `"Main.qml"` (source file) |
| **Runs in** | `logos_host` process | Sandboxed QML engine |
| **Network access** | Yes | No (sandboxed) |
| **Filesystem access** | Yes | Own directory only (sandboxed) |
| **Calls other modules** | Via `LogosAPI`* (C++) | Via `logos.callModule()` (JS) |
| **Nix builder** | `mkLogosModule` | Plain `mkDerivation` |
| **Build time** | Minutes (first build) | Seconds |
---
## What's Next
You now have a complete two-layer Logos application:
```
libcalc (C library)
└── calc_module (Logos core module, wraps libcalc)
└── calc_ui (QML UI, calls calc_module via bridge)
```
From here you could:
- **Add more methods** to `calc_module` and expose them in the UI
- **Call multiple modules** — a QML UI can call any loaded core module, not just one
- **Use the Logos Design System** — the [logos-design-system](https://github.com/logos-co/logos-design-system) QML library provides styled components for a consistent look
- **Add inter-module events** — core modules can emit events via `eventResponse` signals, and QML UIs can listen for them
- **Build a C++ UI module** — for cases where QML sandboxing is too restrictive, you can build a native Qt widget plugin (see the [Developer Guide](logos-developer-guide.md), Section 7.2)
2026-03-06 16:45:49 +00:00