2026-03-06 15:55:00 +00:00
# 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
2026-03-06 15:55:00 +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
2026-03-18 14:22:43 +01:00
- How to build, install, and run your UI inside `logos-basecamp`
2026-03-06 15:55:00 +00:00
**Prerequisites:**
2026-03-06 16:45:49 +00:00
2026-03-06 15:55:00 +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
+---------------------------------------------------------------+
2026-03-18 14:22:43 +01:00
| logos-basecamp |
2026-03-06 15:55:00 +00:00
| 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)
2026-03-18 14:22:43 +01:00
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.
2026-03-06 15:55:00 +00:00
---
## Step 2: Write `metadata.json`
2026-03-18 14:22:43 +01:00
This tells `logos-basecamp` what your plugin is and how to load it.
2026-03-06 15:55:00 +00:00
```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"` |
2026-03-18 14:22:43 +01:00
| `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 |
2026-03-06 15:55:00 +00:00
**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
2026-03-06 15:55:00 +00:00
### 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 ) {
2026-03-18 14:22:43 +01:00
root . errorText = "Logos bridge not available (run inside logos-basecamp)"
2026-03-06 15:55:00 +00:00
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")
```
2026-03-18 14:22:43 +01:00
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 15:55:00 +00:00
2026-03-06 16:54:20 +00:00
### 3.3 The complete file
2026-03-06 15:55:00 +00:00
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 ) {
2026-03-18 14:22:43 +01:00
root . errorText = "Logos bridge not available (run inside logos-basecamp)"
2026-03-06 15:55:00 +00:00
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 |
2026-03-06 15:55:00 +00:00
---
## 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.
---
2026-03-18 14:22:43 +01:00
## Step 6: Run in `logos-basecamp`
2026-03-06 15:55:00 +00:00
2026-03-18 14:22:43 +01:00
### 6.1 Build `logos-basecamp`
2026-03-06 15:55:00 +00:00
```bash
2026-03-18 14:22:43 +01:00
nix build 'github:logos-co/logos-basecamp#app' --out-link ./logos-basecamp
2026-03-06 15:55:00 +00:00
```
### 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:
2026-03-06 15:55:00 +00:00
```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-06 15:55:00 +00:00
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-06 15:55:00 +00:00
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
2026-03-06 15:55:00 +00:00
```
### 6.3 Launch
```bash
2026-03-18 14:22:43 +01:00
./logos-basecamp/bin/logos-basecamp \
2026-03-06 15:55:00 +00:00
--modules-dir ./modules \
--ui-plugins-dir ./ui-plugins
```
You should see:
2026-03-06 16:45:49 +00:00
2026-03-18 14:22:43 +01:00
1. The `logos-basecamp` window opens with a sidebar
2026-03-06 15:55:00 +00:00
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])
2026-03-18 14:22:43 +01:00
4. logos-basecamp: Routes call via IPC to logos_host process
2026-03-06 15:55:00 +00:00
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
2026-03-18 14:22:43 +01:00
QML_UI = $( pwd ) ./logos-basecamp/bin/logos-basecamp \
2026-03-06 15:55:00 +00:00
--modules-dir ./modules \
--ui-plugins-dir ./ui-plugins
```
Edit `Main.qml` , save, and the UI updates without rebuilding.
### 7.2 Debugging
2026-03-18 14:22:43 +01:00
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` .
2026-03-06 15:55:00 +00:00
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 )
}
```
2026-03-18 14:22:43 +01:00
### 7.3 Testing without `logos-basecamp`
2026-03-06 15:55:00 +00:00
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:
2026-03-06 15:55:00 +00:00
```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-06 15:55:00 +00:00
```
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:
2026-03-06 15:55:00 +00:00
```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
2026-03-06 15:55:00 +00:00
./pm/bin/lgpm --modules-dir ./ui-plugins install --file calc_ui.lgx
```
2026-03-18 14:22:43 +01:00
> **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
2026-03-06 15:55:00 +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 |
2026-03-06 15:55:00 +00:00
---
## 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