24 KiB
Tutorial Part 3: Building a C++ UI Module (Process-Isolated)
This is Part 3 of the Logos module tutorial series. In Part 2 you built a QML-only UI plugin. Now you'll build a ui_qml module with a C++ backend — the backend runs in a separate ui-host process while the QML view loads in the host app (basecamp / standalone).
What you'll build: A calc_ui_cpp module with:
- A
.repfile defining the remote interface (slots + properties) - A C++ backend plugin that inherits from the generated
SimpleSourcebase class - A QML view that calls the backend via a typed replica using
logos.watch() - Process isolation: backend crashes can't bring down the host app
Why C++ backend over QML-only?
| QML-only (Part 2) | C++ backend (Part 3) | |
|---|---|---|
| Compilation | None | CMake + Qt |
| Process isolation | No (QML runs in-process) | Yes (C++ in separate ui-host process) |
| Backend calls | logos.callModule() / logos.callModuleAsync() to other modules |
LogosModules typed SDK in C++ |
| Type safety | Args travel as QVariant |
C++ types preserved |
| QML ↔ backend | Direct bridge | Qt Remote Objects (typed replica) |
.rep file |
Not needed | Required — defines the remote interface |
Prerequisites:
- Completed Part 1 — you have a working
calc_modulewith the shared library built (.soon Linux,.dylibon macOS inlogos-calc-module/lib/) - Nix with flakes enabled
Architecture
logos-basecamp / logos-standalone-app
┌─────────────────────────────────────────────┐
│ │
│ QML View (Main.qml) │
│ readonly property var backend: │
│ logos.module("calc_ui_cpp") │
│ logos.watch(backend.add(1,2))│
│ │ │
│ │ Qt Remote Objects (socket) │
└──────────┼──────────────────────────────────┘
│
ui-host process (separate)
┌──────────┼──────────────────────────────────┐
│ ▼ │
│ CalcUiCppPlugin (backend) │
│ : CalcUiCppSimpleSource │
│ : CalcUiCppViewPluginBase │
│ int add(int a, int b) { │
│ return m_logos->calc_module.add(a,b); │
│ } │
│ │ │
│ │ LogosModules typed SDK │
│ ▼ │
│ calc_module (loaded in ui-host) │
└─────────────────────────────────────────────┘
The .rep file declares the interface. At build time, Qt's repc compiler generates:
CalcUiCppSimpleSource— base class the backend inherits fromCalcUiCppReplica— typed replica the QML view usescalc_ui_cpp_replica_factory— separate plugin that the host loads to create typed replicas
Step 1: Scaffold
mkdir logos-calc-ui-cpp && cd logos-calc-ui-cpp
nix flake init -t github:logos-co/logos-module-builder/tutorial-v2#ui-qml-backend
git init && git add -A
This creates the template. We'll customize it for our calculator.
Step 2: metadata.json
{
"name": "calc_ui_cpp",
"version": "1.0.0",
"type": "ui_qml",
"category": "tools",
"description": "Calculator C++ UI — QML view with process-isolated backend",
"main": "calc_ui_cpp_plugin",
"view": "qml/Main.qml",
"icon": "icons/calc.png",
"dependencies": ["calc_module"],
"nix": {
"packages": { "build": [], "runtime": [] },
"external_libraries": [],
"cmake": { "find_packages": [], "extra_sources": [] }
}
}
Key fields:
"type": "ui_qml"— tells the builder this is a QML view module"main": "calc_ui_cpp_plugin"— the backend Qt plugin library (without extension)"view": "qml/Main.qml"— the QML entry point"dependencies": ["calc_module"]— core modules the backend calls
Step 3: The .rep File
Create src/calc_ui_cpp.rep:
class CalcUiCpp
{
PROP(QString status READWRITE)
SLOT(int add(int a, int b))
SLOT(int multiply(int a, int b))
SLOT(int factorial(int n))
SLOT(int fibonacci(int n))
SLOT(QString libVersion())
}
This is the single source of truth for the remote interface. repc generates:
rep_calc_ui_cpp_source.h—CalcUiCppSimpleSourcewith virtual slots the backend overridesrep_calc_ui_cpp_replica.h—CalcUiCppReplicawith typed methods and auto-synced properties
PROP values auto-sync from backend to QML replica. SLOT return values are delivered as QRemoteObjectPendingReply — use logos.watch() in QML to get them as JS Promises.
Step 3.1: Update the interface header
The scaffolded template may create an interface file like src/ui_example_interface.h. Rename it to match this tutorial and make sure the class/IID names are updated, or the plugin metadata wiring will break.
# If your scaffold created ui_example files, rename the interface header:
mv src/ui_example_interface.h src/calc_ui_cpp_interface.h
Set src/calc_ui_cpp_interface.h to:
#pragma once
#include <QObject>
#include <QString>
#include "interface.h"
class CalcUiCppInterface : public PluginInterface
{
public:
virtual ~CalcUiCppInterface() = default;
};
#define CalcUiCppInterface_iid "org.logos.CalcUiCppInterface"
Q_DECLARE_INTERFACE(CalcUiCppInterface, CalcUiCppInterface_iid)
Your plugin header should then include calc_ui_cpp_interface.h and use:
Q_PLUGIN_METADATA(IID CalcUiCppInterface_iid FILE "metadata.json")Q_INTERFACES(CalcUiCppInterface)
If the interface filename or IID symbol doesn't match, you'll typically get build errors (missing header/symbol) or plugin-load failures at runtime.
Step 4: CMakeLists.txt
cmake_minimum_required(VERSION 3.14)
project(CalcUiCppPlugin LANGUAGES CXX)
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
else()
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
endif()
logos_module(
NAME calc_ui_cpp
REP_FILE src/calc_ui_cpp.rep
SOURCES
src/calc_ui_cpp_interface.h
src/calc_ui_cpp_plugin.h
src/calc_ui_cpp_plugin.cpp
)
REP_FILE tells logos_module() to:
- Run
repcto generate source/replica headers - Generate
LogosViewPluginBase(typed remoting base class) - Build a separate
calc_ui_cpp_replica_factoryshared library
Step 5: C++ Backend Plugin
src/calc_ui_cpp_plugin.h
#pragma once
#include <QString>
#include <QVariantList>
#include "calc_ui_cpp_interface.h"
#include "LogosViewPluginBase.h"
#include "rep_calc_ui_cpp_source.h"
class LogosAPI;
class LogosModules;
class CalcUiCppPlugin : public CalcUiCppSimpleSource,
public CalcUiCppInterface,
public CalcUiCppViewPluginBase
{
Q_OBJECT
Q_PLUGIN_METADATA(IID CalcUiCppInterface_iid FILE "metadata.json")
Q_INTERFACES(CalcUiCppInterface)
public:
explicit CalcUiCppPlugin(QObject* parent = nullptr);
~CalcUiCppPlugin() override;
QString name() const override { return "calc_ui_cpp"; }
QString version() const override { return "1.0.0"; }
Q_INVOKABLE void initLogos(LogosAPI* api);
// Slots from .rep — override the generated virtuals
int add(int a, int b) override;
int multiply(int a, int b) override;
int factorial(int n) override;
int fibonacci(int n) override;
QString libVersion() override;
signals:
void eventResponse(const QString& eventName, const QVariantList& args);
private:
LogosAPI* m_logosAPI = nullptr;
LogosModules* m_logos = nullptr;
};
Three base classes:
CalcUiCppSimpleSource— generated from.rep, provides the typed source for Qt Remote ObjectsCalcUiCppInterface— standard Logos plugin interface (name(),version())CalcUiCppViewPluginBase— generated, providessetBackend()andenableRemoting()
src/calc_ui_cpp_plugin.cpp
#include "calc_ui_cpp_plugin.h"
#include "logos_api.h"
#include "logos_sdk.h"
CalcUiCppPlugin::CalcUiCppPlugin(QObject* parent)
: CalcUiCppSimpleSource(parent) {}
CalcUiCppPlugin::~CalcUiCppPlugin() { delete m_logos; }
void CalcUiCppPlugin::initLogos(LogosAPI* api)
{
m_logosAPI = api;
m_logos = new LogosModules(api);
// Register this object as the Remote Objects source
setBackend(this);
}
int CalcUiCppPlugin::add(int a, int b)
{
return m_logos->calc_module.add(a, b);
}
int CalcUiCppPlugin::multiply(int a, int b)
{
return m_logos->calc_module.multiply(a, b);
}
int CalcUiCppPlugin::factorial(int n)
{
return m_logos->calc_module.factorial(n);
}
int CalcUiCppPlugin::fibonacci(int n)
{
return m_logos->calc_module.fibonacci(n);
}
QString CalcUiCppPlugin::libVersion()
{
return m_logos->calc_module.libVersion();
}
Key points:
- Constructor calls
CalcUiCppSimpleSource(parent)— notQObject(parent) initLogos()callssetBackend(this)to register with the Remote Objects host- Slots return values directly — they travel back to the QML replica via Qt Remote Objects
m_logos->calc_module.add(a, b)uses the generated typed SDK (type-safe, no QVariant)
Step 6: QML View
Create src/qml/Main.qml:
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
property string result: ""
property string errorText: ""
// Typed replica of the backend running in ui-host
readonly property var backend: logos.module("calc_ui_cpp")
// "status" property from the .rep — auto-synced via Qt Remote Objects
readonly property string status: backend ? backend.status : ""
function callCalc(method, args) {
if (!backend) {
root.errorText = "Backend not available"
return
}
root.errorText = ""
root.result = "..."
// logos.watch() wraps the pending reply in a JS Promise
logos.watch(backend[method].apply(backend, args),
function(value) { root.result = String(value) },
function(error) { root.errorText = String(error) }
)
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 24
spacing: 16
Text {
text: "Calculator (C++ backend)"
font.pixelSize: 20
color: "#ffffff"
}
RowLayout {
spacing: 12
TextField {
id: inputA; placeholderText: "a"
Layout.preferredWidth: 80
validator: IntValidator {}
}
TextField {
id: inputB; placeholderText: "b"
Layout.preferredWidth: 80
validator: IntValidator {}
}
Button {
text: "Add"
onClicked: root.callCalc("add", [parseInt(inputA.text) || 0,
parseInt(inputB.text) || 0])
}
Button {
text: "Multiply"
onClicked: root.callCalc("multiply", [parseInt(inputA.text) || 0,
parseInt(inputB.text) || 0])
}
}
Rectangle {
Layout.fillWidth: true; height: 56
color: root.errorText ? "#3d1a1a" : "#1a2d1a"
radius: 8
Text {
anchors.centerIn: parent
text: root.errorText || root.result || "Press a button"
color: root.errorText ? "#f85149" : "#56d364"
font.pixelSize: 15
}
}
Text {
text: "Backend status: " + root.status
color: "#8b949e"; font.pixelSize: 13
}
}
}
Key patterns:
logos.module("calc_ui_cpp")— gets the typed replica (auto-synced properties)backend.status— PROP from.rep, updates automaticallylogos.watch(backend.add(1, 2), ...)— SLOT return value as JS Promise- ``— required for
logos.watch()
Step 6.5: Use the Logos Design System in your QML
The QML you load above runs inside the host (logos-basecamp / logos-standalone-app), which already has logos-design-system on the QML import path. Use its themed components rather than rolling your own visuals — your module gets the polished look automatically as the design system evolves.
import Logos.Theme
import Logos.Controls
import Logos.Icons // optional shared icon assets
LogosButton {
text: qsTr("Add")
onClicked: root.callCalc("add", [parseInt(inputA.text) || 0,
parseInt(inputB.text) || 0])
}
LogosTextField {
id: inputA
placeholderText: qsTr("a")
}
Rectangle {
color: Theme.palette.backgroundSecondary
radius: Theme.spacing.radiusSmall
LogosText { text: qsTr("Result"); color: Theme.palette.text }
}
Discover what's available by running the storybook:
cd repos/logos-design-system && nix run
The sidebar splits components into:
- Controls — designed per Figma, production-ready (
LogosButton,LogosBadge,LogosCheckbox,LogosComboBox,LogosIconButton,LogosPaginator,LogosSearchBar,LogosTabBar,LogosTable,LogosText,LogosTextField,LogosToolTip, …). - Controls (not designed) — placeholders with stable APIs but unstyled visuals (
LogosDialog,LogosDrawer,LogosScrollView,LogosSpinner,LogosTextArea,LogosSwitch, …). You can ship with them; they'll get the polished look applied later without you having to change your QML.
Theme tokens (use these instead of hex literals or magic font sizes):
Theme.palette.*—background,backgroundSecondary,surface,text,textSecondary,border,primary,success,warning,error,info,hover,pressed, …Theme.spacing.*—tiny,small,medium,large,xlarge,xxlarge,radiusSmall,radiusMedium,radiusLargeTheme.typography.*—pageTitleText(36),titleText(30),panelTitleText(24),subtitleText(16),primaryText(14),secondaryText(12);weightRegular/weightMedium/weightBold;publicSansLogos.Icons.LogosIcons.*—arrowLeft,arrowRight,refresh,install,trash,more,search, …
Feedback and contributions
Feel free to report bugs, file feature requests, or contribute components / theme tokens upstream — all welcome at logos-co/logos-design-system. The same fix lifts every consumer, so upstreaming is the most impactful path. If you can sketch the public API you'd like to use in a feature request, it makes review and implementation much faster.
Step 7: flake.nix
{
description = "Calculator C++ UI plugin — QML view with process-isolated backend";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder/tutorial-v2";
# Option A: point to a remote repo (for CI or when calc_module is published)
calc_module.url = "github:logos-co/logos-tutorial/tutorial-v2?dir=logos-calc-module";
# Option B: point to your local checkout (for local development)
# calc_module.url = "path:../logos-calc-module";
};
outputs = inputs@{ logos-module-builder, ... }:
logos-module-builder.lib.mkLogosQmlModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
}
The calc_module input attribute name must match the dependency name in metadata.json. The URL can be:
github:— fetches from a remote GitHub repo. Use for CI or whencalc_moduleis published.path:— points to a local directory on disk (e.g.,path:../logos-calc-module). Use during local development.
Important:
calc_modulebundles a small C library (libcalc) compiled from source during its own Nix build — there's no prebuilt.so/.dylibto stage. Buildingcalc_module(which happens automatically when this app pulls it in) compiles it for you. See Part 1, Step 1.5.
mkLogosQmlModule handles everything: compiles the C++ backend (because main is set), bundles the QML view, generates LGX packages, and wires up nix run.
Step 8: Build and Run
First, make sure your local calc_module builds cleanly — its C library is compiled from source during the Nix build (from lib/libcalc.c via lib/Makefile), so there's nothing to pre-build by hand (see Part 1, Step 1.5):
cd ../logos-calc-module && git add -A && nix build && cd -
Then build and run. Choose the approach that matches your flake.nix setup:
git add -A
# If flake.nix uses path:../logos-calc-module — just run directly:
nix run
# If flake.nix uses github: — override to use your local checkout:
nix run --override-input calc_module path:../logos-calc-module
# Or from the workspace:
./scripts/ws run logos-calc-ui-cpp --local logos-calc-ui-cpp logos-calc-module
Live reloading QML with DEV_QML_PATH
For QML iteration, point DEV_QML_PATH at the directory that contains your view entry's basename (from metadata.json "view"). This tutorial sets "view": "qml/Main.qml", so the directory must contain Main.qml (here: qml/ at the repo root):
DEV_QML_PATH=$PWD/qml nix run .
When DEV_QML_PATH is set, logos-standalone-app loads QML from your source tree at runtime instead of the installed copy — so edits to Main.qml (and any QML under that tree) are picked up on the next relaunch without you having to re-sync files.
Important — what this does not skip. nix run always re-evaluates the flake and rehashes the source tree before launching. By default src = ./. includes every tracked file, including *.qml — so:
- Any source change, including QML edits, rebuilds the plugin before the app starts.
DEV_QML_PATHonly kicks in after the build is done; it doesn't shortcut the rebuild itself. - C++ /
.rep/metadata.json/ CMake changes rebuild as normal. - The flake-evaluation overhead on each
nix runis fixed and unavoidable while invoking through nix.
For the absolute fastest loop (no nix involvement after the first build), do the build once and run the resulting binary directly:
# Build once — populates result/ in the nix store
nix build .
# Subsequent runs: invoke the bundled standalone wrapper directly,
# skipping nix entirely. DEV_QML_PATH still redirects QML loading.
DEV_QML_PATH=$PWD/qml ./result/bin/run-logos-standalone-ui
(Adjust the binary name to whatever ls result/bin/ shows on your build.)
Naming: Only
DEV_QML_PATHis honored bylogos-standalone-app. Seerepos/logos-standalone-app/README.md.
This does not work with
logos-basecamp— Basecamp loads QML plugins from its own install tree, so source edits are not picked up until you rebuild and reinstall the.lgx.
Step 9: How the Pieces Connect
nix build→ compiles the C++ plugin + replica factory, bundles QML viewnix run→ launcheslogos-standalone-appwhich:- Loads
calc_module(dependency) - Spawns a
ui-hostchild process withcalc_ui_cpp_plugin.so ui-hostcallsinitLogos()→setBackend(this)→enableRemoting(host)- Backend is now accessible over a local socket
- Loads
- Host app loads
calc_ui_cpp_replica_factory.dylib→ creates a typed replica - QML gets the replica via
logos.module("calc_ui_cpp") backend.add(1, 2)→ Qt Remote Objects sends call to ui-host → backend runs → returns resultbackend.statusauto-syncs whenever the backend callssetStatus(...)
Step 10: UI Integration Tests (Optional)
Add automated UI tests using the logos-qt-mcp test framework. Just create .mjs files in tests/ and logos-module-builder auto-wires nix build .#integration-test.
Create tests/ui-tests.mjs:
import { resolve } from "node:path";
// CI sets LOGOS_QT_MCP automatically; for interactive use: nix build .#test-framework -o result-mcp
const root =
process.env.LOGOS_QT_MCP ||
new URL("../result-mcp", import.meta.url).pathname;
const { test, run } = await import(
resolve(root, "test-framework/framework.mjs")
);
test("calc_ui_cpp: loads and shows title", async (app) => {
await app.waitFor(
async () => {
await app.expectTexts(["UI Example (C++ backend)"]);
},
{ timeout: 15000, interval: 500, description: "UI to load" },
);
});
test("calc_ui_cpp: shows connection status", async (app) => {
await app.expectTexts(["Connecting to backend..."]);
});
test("calc_ui_cpp: add button visible", async (app) => {
await app.expectTexts(["Add"]);
});
run();
git add tests/
# Hermetic CI test
nix build .#integration-test -L
# Interactive
nix build .#test-framework -o result-mcp
nix run . # app with inspector on :3768
node tests/ui-tests.mjs # in another terminal
Comparison: .rep Interface Patterns
| Pattern | .rep declaration | Backend C++ | QML usage |
|---|---|---|---|
| Return value | SLOT(int add(int a, int b)) |
int add(...) override { return ...; } |
logos.watch(backend.add(1,2), cb) |
| Property | PROP(QString status READWRITE) |
setStatus("Ready") (inherited) |
backend.status (auto-syncs) |
| Signal | SIGNAL(errorOccurred(QString msg)) |
emit errorOccurred("fail") |
Connections { target: backend; function onErrorOccurred(msg) {...} } |
| Model | (use Q_PROPERTY on backend) | Q_PROPERTY(QAbstractItemModel* items ...) |
logos.model("calc_ui_cpp", "items") |
Next Steps
- Add more
.repproperties/signals for richer UI state - Use
logos.model()for list views backed byQAbstractItemModel - Package as
.lgxfor distribution:nix build .#lgx - Use the Logos Design System in your QML — see Step 6.5. Browse components in the storybook (
cd repos/logos-design-system && nix run); file issues atlogos-co/logos-design-system(bugs on designed components, feature type for new components / variants / theme tokens). - See logos-package-manager-ui for a production example