19 KiB
Tutorial Part 2: Building a QML UI for Your Logos Module
This is Part 2 of the Logos module tutorial series. In Part 1 you wrapped a C library as a Logos core module. Now you'll build a QML user interface that calls that module — first isolated with nix run, then packaged and loaded into logos-basecamp.
What you'll build: A calc_ui QML plugin with input fields and buttons that call calc_module methods (add, multiply, factorial, fibonacci) through the Logos bridge.
What you'll learn:
- 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 package and install your UI into
logos-basecamp
Prerequisites:
- Completed Part 1 — 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 |
| Main.qml (QML) | IPC (Qt Remote Objects) | C++ plugin |
+-------------------+ +-------------------+
^ ^
└──────────────── loaded by ───────────────────────┘
logos-basecamp / logos-standalone-app
Key points:
- No compilation. A QML plugin is just
.qmlfiles and ametadata.json. - Sandboxed. No network access, no filesystem access outside the module directory.
- The
logosbridge is injected by the host. Call core modules withlogos.callModule("module", "method", [args]). - Entry point is defined by the required
"view"field inmetadata.json(for this tutorial it isMain.qml).
Step 1: Scaffold
Use the QML module template from logos-module-builder:
mkdir logos-calc-ui && cd logos-calc-ui
nix flake init -t github:logos-co/logos-module-builder#ui-qml
git init && git add -A
Note: The generated
flake.nixuses an unpinnedlogos-module-builderURL. Replace it with the pinned version shown in Step 4 to ensure reproducible builds.
This gives you:
logos-calc-ui/
├── flake.nix # Nix build + nix run support
├── metadata.json # Plugin metadata
└── Main.qml # Your UI (starter template)
Step 2: Update metadata.json
Replace the template contents with your plugin's details. The template may generate an extra nix section — keep it as-is, it's used by the builder:
{
"name": "calc_ui",
"version": "1.0.0",
"description": "Calculator UI - QML frontend for the calc_module",
"type": "ui_qml",
"view": "Main.qml",
"dependencies": ["calc_module"],
"category": "tools",
"icon": "icons/calc.png",
"nix": {
"packages": {
"build": [],
"runtime": []
},
"external_libraries": [],
"cmake": {
"find_packages": [],
"extra_sources": [],
"extra_include_dirs": [],
"extra_link_libraries": []
}
}
}
Create the icon directory and add a placeholder icon. The icon is displayed in the logos-basecamp sidebar when the module is loaded:
mkdir -p icons
# Copy any PNG here — or generate a 64×64 placeholder:
echo "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAmElEQVR4nO3QMREAIBDAsFeEN3ziCWRkoEP2XmedfX82OkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAO0BN/SiO/PatoIAAAAASUVORK5CYII=" | base64 -d > icons/calc.png
The view field tells the host which QML file to load for the UI. The dependencies field tells the host to load calc_module before showing your UI.
Naming convention: Each entry in
dependenciesmust match thenamefield in that module's ownmetadata.json. When adding a dependency as a flake input, the input attribute name must also match the dependency name — e.g.,calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module". The URL can point to any repo, but the attribute name is how the builder resolves dependencies.
Step 3: Write Main.qml
Replace the starter file with the calculator UI:
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
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: "#ffffff"
Layout.alignment: Qt.AlignHCenter
}
// ── Two-operand operations ─────────────────────────────
RowLayout {
spacing: 12
Layout.fillWidth: true
TextField {
id: inputA
placeholderText: "a"
Layout.preferredWidth: 80
validator: IntValidator {}
}
TextField {
id: inputB
placeholderText: "b"
Layout.preferredWidth: 80
validator: IntValidator {}
}
Button {
text: "Add"
onClicked: callTwoOp("add", inputA.text, inputB.text)
}
Button {
text: "Multiply"
onClicked: callTwoOp("multiply", inputA.text, inputB.text)
}
}
// ── Single-operand operations ──────────────────────────
RowLayout {
spacing: 12
Layout.fillWidth: true
TextField {
id: inputN
placeholderText: "n"
Layout.preferredWidth: 80
validator: IntValidator { bottom: 0 }
}
Button {
text: "Factorial"
onClicked: callOneOp("factorial", inputN.text)
}
Button {
text: "Fibonacci"
onClicked: callOneOp("fibonacci", inputN.text)
}
Button {
text: "libcalc version"
onClicked: callModule("libVersion", [])
}
}
// ── Result display ─────────────────────────────────────
Rectangle {
Layout.fillWidth: true
height: 56
color: root.errorText.length > 0 ? "#3d1a1a" : "#1a2d1a"
radius: 8
Text {
anchors.centerIn: parent
text: root.errorText.length > 0 ? root.errorText
: (root.result.length > 0 ? root.result : "Enter values and press a button")
color: root.errorText.length > 0 ? "#f85149" : "#56d364"
font.pixelSize: 15
}
}
Item { Layout.fillHeight: true }
}
// ── Logos bridge helpers ───────────────────────────────────
function callModule(method, args) {
root.errorText = ""
root.result = ""
if (typeof logos === "undefined" || !logos.callModule) {
root.errorText = "Logos bridge not available"
return
}
root.result = String(logos.callModule("calc_module", method, args))
}
function callTwoOp(method, a, b) {
if (a === "" || b === "") { root.errorText = "Enter values for 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)])
}
}
The logos object is injected by the host at runtime. The callModule helper checks for it and routes calls through the IPC bridge to calc_module.
Step 4: Update flake.nix
The template already has everything wired up. Update the description and add calc_module as a dependency input:
{
description = "Calculator QML UI Plugin for Logos - frontend for calc_module";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module"; # must match dependency name in metadata.json
};
outputs = inputs@{ logos-module-builder, ... }:
logos-module-builder.lib.mkLogosQmlModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
}
mkLogosQmlModule handles everything — it stages QML files, metadata, and icons into a plugin directory, bundles all module dependencies (direct and transitive) from their LGX packages, and automatically wires up apps.default so nix run . launches the UI in a standalone window with all required backend modules self-contained. flakeInputs = inputs passes all inputs so that dependencies declared in metadata.json are resolved automatically — note that the input attribute name (calc_module) must match the dependency name.
Step 5: Test with nix run
5.1 UI only (layout preview)
git add -A
nix flake update # regenerate flake.lock to match the pinned inputs in flake.nix
git add flake.lock
nix run .
The app opens immediately. No modules are loaded, so clicking buttons shows "Logos bridge not available" — but you can verify the layout and styling look correct.
5.2 Full functionality (with modules)
The standalone app automatically bundles and loads all module dependencies declared in metadata.json. To test with your local calc_module from Part 1:
nix run . --override-input calc_module path:../logos-calc-module
Clicking Add, Multiply, Factorial, or Fibonacci now calls the real module.
Step 6: Using the Logos Design System
logos-basecamp has logos-design-system on its QML import path. You can use its themed components directly without any extra setup in your module.
import Logos.Theme 1.0
import Logos.Controls 1.0
Replace the plain Button and TextField with the styled equivalents:
// Instead of Button:
LogosButton {
text: "Add"
onClicked: callTwoOp("add", inputA.text, inputB.text)
}
// Instead of TextField:
LogosTextField {
id: inputA
placeholderText: "a"
}
// Use theme colors instead of hardcoded hex values:
Rectangle {
color: Theme.palette.backgroundSecondary
// ...
Text { color: Theme.palette.text }
}
Available components: LogosButton, LogosTextField, LogosText, LogosTabButton.
Available theme tokens via Theme.palette:
- Colors:
background,backgroundSecondary,backgroundMuted,text,textMuted,border,overlayOrange - Spacing:
Theme.spacing.radiusSmall,Theme.spacing.radiusXlarge - Typography:
Theme.typography.secondaryText,Theme.typography.weightMedium
Step 7: Load in logos-basecamp
7.1 Bundle as LGX packages
Create .lgx packages for both dev and portable variants. Use --out-link to avoid overwriting the result symlink:
# Package calc_module (from Part 1)
cd ../logos-calc-module
nix build '.#lgx' --out-link result-lgx
nix build '.#lgx-portable' --out-link result-lgx-portable
# Package the QML UI plugin
cd ../logos-calc-ui
nix build '.#lgx' --out-link result-lgx
nix build '.#lgx-portable' --out-link result-lgx-portable
For more bundling options (standalone bundler syntax, cross-platform packaging), see the Developer Guide — Bundling with nix-bundle-lgx.
7.2 Build and run logos-basecamp
Build logos-basecamp, launch it once to preinstall its bundled modules, then install your modules.
Note:
logos-basecampdoes not accept--modules-diror--ui-plugins-dirCLI flags. It manages its own data directory and preinstalls bundled modules (main_ui, package_manager, etc.) on first launch.
# Build logos-basecamp
nix build 'github:logos-co/logos-basecamp' -o basecamp-result
# Launch once to preinstall bundled modules, then close it
./basecamp-result/bin/logos-basecamp
Basecamp creates its data directory on first launch. To find where it is, check the log output for plugins directory or look for the directory that contains modules/ and plugins/ subdirectories:
# macOS (typical path, may vary):
ls ~/Library/Application\ Support/Logos/
# Linux (typical path, may vary):
ls ~/.local/share/Logos/
The dev build directory is named LogosBasecampDev (portable builds use LogosBasecamp).
Install your modules using lgpm. First, set BASECAMP_DIR to your platform's path:
# macOS:
BASECAMP_DIR="$HOME/Library/Application Support/Logos/LogosBasecampDev"
# Linux:
BASECAMP_DIR="$HOME/.local/share/Logos/LogosBasecampDev"
# Build lgpm CLI
nix build 'github:logos-co/logos-package-manager#cli' --out-link ./pm
# Install core module
./pm/bin/lgpm --modules-dir "$BASECAMP_DIR/modules" \
install --file ../logos-calc-module/result-lgx/*.lgx
# Install UI plugin
./pm/bin/lgpm --ui-plugins-dir "$BASECAMP_DIR/plugins" \
install --file result-lgx/*.lgx
# Launch basecamp -- your modules appear alongside the built-in ones
./basecamp-result/bin/logos-basecamp
7.3 Portable basecamp build (optional)
The dev build above depends on nix store paths at runtime. For a self-contained portable build that works without nix:
# Build portable basecamp (bundles all Qt frameworks/libraries)
nix build 'github:logos-co/logos-basecamp#bin-bundle-dir' -o basecamp-portable
# Launch once to preinstall bundled modules
./basecamp-portable/bin/logos-basecamp
The portable build uses a different data directory (LogosBasecamp instead of LogosBasecampDev). Set BASECAMP_DIR to your platform's path:
# macOS:
BASECAMP_DIR="$HOME/Library/Application Support/Logos/LogosBasecamp"
# Linux:
BASECAMP_DIR="$HOME/.local/share/Logos/LogosBasecamp"
Install your modules using the portable .lgx variants:
# Install core module (use portable variant)
./pm/bin/lgpm --modules-dir "$BASECAMP_DIR/modules" \
install --file ../logos-calc-module/result-lgx-portable/*.lgx
# Install UI plugin (use portable variant)
./pm/bin/lgpm --ui-plugins-dir "$BASECAMP_DIR/plugins" \
install --file result-lgx-portable/*.lgx
# Launch
./basecamp-portable/bin/logos-basecamp
Important: Portable basecamp requires portable
.lgxvariants (result-lgx-portable), and the dev build requires dev variants (result-lgx). Mixing them will cause loading failures.
7.4 Install via logos-basecamp UI
Instead of using lgpm on the command line, you can install modules through the basecamp UI:
- Launch
logos-basecamp - Go to Package Manager
- Click Install from file
- Select
../logos-calc-module/result-lgx/*.lgx— installscalc_module - Repeat for
result-lgx/*.lgx— installscalc_ui
The "Calculator UI" tab appears in the sidebar. Clicking it loads your Main.qml.
7.5 Live reloading with logos-standalone-app
For rapid iteration on QML without rebuilding, set QML_PATH to your QML source directory:
QML_PATH=$PWD nix run .
Edit Main.qml, close and re-run — changes appear immediately without nix build. When QML_PATH is set, the plugin loads QML files from the filesystem instead of from Qt resources, so your edits are picked up on each launch.
This does not work with
logos-basecamp. Basecamp loads QML plugins from its own data directory, so changes to your source files are not reflected until you rebuild and reinstall the.lgxpackage.
7.6 Testing without any runtime
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.
# If you have Qt installed
qml Main.qml
Known Limitations
QML-to-C++ type coercion
When calling C++ module methods from QML via logos.callModule(), arguments are passed through IPC as QVariant values. The runtime automatically coerces mismatched types to match the target method signature — for example, a double sent from QML will be converted to int if the method expects int, and numeric strings will be converted to their numeric types.
This means you can define methods with their natural parameter types (int, bool, double, etc.) and calls from QML will work without manual conversion:
// This works — the runtime coerces arguments automatically
Q_INVOKABLE int add(int a, int b) { return a + b; }
Note: Type coercion uses
QVariant::convert(), which rounds (not truncates) when convertingdoubletoint— e.g.,3.7becomes4.
QML changes not appearing after rebuild
Qt caches compiled QML on disk. If you update your Main.qml, rebuild and reinstall the .lgx, but the old UI still appears, the cache is stale. Fix by disabling the cache before launching:
QML_DISABLE_DISK_CACHE=1 ./basecamp-result/bin/logos-basecamp
UI module not loading or basecamp behaving unexpectedly
When switching between portable and dev builds of basecamp, or running multiple basecamp instances, the data directory can get into a bad state (stale modules, mixed variants, corrupted preinstall). Clear it and let basecamp re-preinstall on next launch:
# Remove basecamp's data directory
# macOS:
rm -rf ~/Library/Application\ Support/Logos/LogosBasecampDev
# Linux:
rm -rf ~/.local/share/Logos/LogosBasecampDev
# Relaunch — basecamp will re-preinstall its bundled modules
./basecamp-result/bin/logos-basecamp
Then reinstall your custom modules.
Recap
| Core Module (Part 1) | QML UI Plugin (Part 2) | |
|---|---|---|
| Language | C++ | QML / JavaScript |
| Files | .cpp, .h, CMakeLists.txt, metadata.json |
Main.qml, metadata.json |
| Compilation | Yes (CMake → .so) |
No (file copy) |
metadata.type |
"core" |
"ui_qml" |
| Test command | logoscore -m ./result/lib -l calc_module |
nix run . |
| Calls other modules | Via LogosAPI* (C++) |
Via logos.callModule() (JS) |
What's Next
- Add more methods to
calc_moduleand call them from QML - Use Logos Design System styled components for consistent look and feel
- Build a C++ UI module for cases where QML sandboxing is too restrictive — see Developer Guide, Section 7.2