Match the templates: read the name via file(READ metadata.json) +
string(JSON ... name) and pass NAME ${MODULE_NAME} to logos_module(), so the
module name has a single source of truth. Same string(JSON) pattern the
converted templates use (build-verified there).
34 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).
You'll use the universal authoring model: set "interface": "universal" in metadata.json and write exactly two things — the .rep (your view contract) and a *Backend class that implements it. The *Plugin and *Interface classes, the initLogos(LogosAPI*) wiring, and the typed-SDK construction are all generated for you. This is the same model Part 1 used for the calc_module core module (interface: universal), now applied to a UI module.
What you'll build: A calc_ui_cpp module with:
- A
.repfile defining the remote interface (slots) — the one Qt-typed contract you author - A C++
*Backendclass that derives the generatedSimpleSource(implements the.rep) andLogosModuleContext(givesmodules()typed callers, event subscriptions, andonContextReady()) - 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
You write only the .rep and the Backend. The *Plugin/*Interface classes, the initLogos/setBackend wiring, and the typed SDK are generated.
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 |
modules() typed SDK in C++ (type-safe, no QVariant) |
| Type safety | Args travel as QVariant |
C++ types preserved |
| QML ↔ backend | Direct bridge | Qt Remote Objects (typed replica) |
.rep file |
Not needed | Required — your view contract, the one file you author |
| C++ you write | None | One *Backend class — no hand-written plugin/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)
┌──────────┼──────────────────────────────────┐
│ ▼ │
│ CalcUiCppBackend (you write this) │
│ : CalcUiCppSimpleSource (impl .rep) │
│ : LogosModuleContext (modules()) │
│ int add(int a, int b) override { │
│ return modules().calc_module.add(a,b);│
│ } │
│ │ │
│ │ modules() typed SDK │
│ ▼ │
│ calc_module (loaded in ui-host) │
└─────────────────────────────────────────────┘
You author two files: the .rep (your view contract) and the *Backend class. Everything else is generated.
The .rep file declares the interface. At build time, Qt's repc compiler generates:
CalcUiCppSimpleSource— base class the backend implementsCalcUiCppReplica— typed replica the QML view usescalc_ui_cpp_replica_factory— separate plugin that the host loads to create typed replicas
And because metadata.json sets "interface": "universal", the builder also generates the plumbing a classic plugin made you hand-write:
CalcUiCppInterface— the Logos plugin interface (name(),version())CalcUiCppPlugin— theQ_OBJECTplugin withQ_PLUGIN_METADATA,initLogos(LogosAPI*), and thesetBackend()/enableRemoting()wiring — built around your*Backend
Your *Backend derives LogosModuleContext, which gives it the same surface a universal core module impl gets: modules() typed method callers for your dependencies, typed event subscriptions (modules().dep.on<Event>(...)), and onContextReady() (fires when the backend is wired, so subscriptions are live before the view's first call). calc_module here is call-only, but for an event-driven PROP fed from a typed subscription see the worked ui-typed-backend doc-test.
Step 1: Scaffold
Create a new directory and initialise it from the C++ backend UI template:
mkdir logos-calc-ui-cpp && cd logos-calc-ui-cpp
nix flake init -t github:logos-co/logos-module-builder#ui-qml-backend
This scaffolds the universal UI backend template: a metadata.json with "interface": "universal", an example .rep (src/ui_example.rep), and a single *Backend class (src/ui_example_backend.h / .cpp) — no hand-written interface or plugin files. We'll replace the ui_example files with our calculator's .rep + backend.
rm -f src/ui_example.rep src/ui_example_backend.h src/ui_example_backend.cpp
Remove the example .rep and backend — we replace them with the calc_ui_cpp equivalents in the steps below. (There are no *_interface.h / *_plugin.{h,cpp} files to remove: in the universal model those are generated, not authored.)
git init && git add -A
Step 2: metadata.json
Replace the template contents with your plugin's details:
{
"name": "calc_ui_cpp",
"version": "1.0.0",
"type": "ui_qml",
"interface": "universal",
"category": "tools",
"description": "Calculator C++ UI — QML view with process-isolated backend for calc_module",
"main": "calc_ui_cpp_plugin",
"view": "qml/Main.qml",
"icon": "icons/calc.png",
"dependencies": ["calc_module"],
"codegen": { "rep": "src/calc_ui_cpp.rep" },
"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 (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
Key fields:
"type": "ui_qml"— tells the builder this is a QML view module"interface": "universal"— selects the universal authoring model: you write the.rep+ a*Backendclass, and the*Plugin/*Interfaceglue is generated. Without this key, the builder expects the classic hand-writteninitLogos(LogosAPI*)plugin."codegen": { "rep": "src/calc_ui_cpp.rep" }— names your view contract. (backend_class/backend_headerare also overridable, defaulting toCalcUiCppBackend/calc_ui_cpp_backend.h.)"main": "calc_ui_cpp_plugin"— the generated backend Qt plugin library (without extension)"view": "qml/Main.qml"— the QML entry point"dependencies": ["calc_module"]— core modules the backend calls viamodules()
Step 3: The .rep File
Create src/calc_ui_cpp.rep:
class CalcUiCpp
{
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, and the one Qt-typed file you author — the .rep uses Qt types (QString) because that's the Qt Remote Objects wire contract. repc generates:
rep_calc_ui_cpp_source.h—CalcUiCppSimpleSourcewith virtual slots your*Backendoverridesrep_calc_ui_cpp_replica.h—CalcUiCppReplicawith typed methods the QML view calls
SLOT return values are delivered as QRemoteObjectPendingReply — use logos.watch() in QML to get them as JS Promises. You can also declare PROP entries (e.g. PROP(QString status READWRITE)) which auto-sync from the backend to the QML replica — see the ui-typed-backend doc-test for a PROP fed from a typed module-event subscription.
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()
# Derive the module name from metadata.json — single source of truth.
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/metadata.json" METADATA_JSON)
string(JSON MODULE_NAME GET ${METADATA_JSON} name)
logos_module(
NAME ${MODULE_NAME}
REP_FILE src/calc_ui_cpp.rep
SOURCES
src/calc_ui_cpp_backend.h
src/calc_ui_cpp_backend.cpp
INCLUDE_DIRS
src
)
You list only your two authored sources — the *Backend header and implementation. REP_FILE points at your .rep; the generated *Plugin glue in generated_code/ is compiled automatically. REP_FILE tells logos_module() to:
- Run
repcto generate the source/replica headers - Generate the
*Plugin/*Interfacewrapper around your*Backend(becausemetadata.jsonsets"interface": "universal") - Build a separate
calc_ui_cpp_replica_factoryshared library
Step 5: C++ Backend
Now write the backend — the only C++ you author. It's a single class that derives:
CalcUiCppSimpleSource— generated byrepcfrom your.rep; you override its slots. The QML replica receives each return value via Qt Remote Objects.LogosModuleContext— givesmodules()(typed callers + event subscriptions for yourdependencies) andonContextReady(), exactly like a universal core module impl.
There is no *_interface.h and no *_plugin.{h,cpp} to write — the builder generates the *Plugin (Q_OBJECT, Q_PLUGIN_METADATA, initLogos, setBackend()/enableRemoting()) and *Interface (name(), version()) around this class.
5.1 src/calc_ui_cpp_backend.h
#pragma once
#include "rep_calc_ui_cpp_source.h"
#include "logos_module_context.h"
// The whole hand-written backend. Derives:
// - CalcUiCppSimpleSource — generated from calc_ui_cpp.rep; override its
// slots (the QML replica gets each return value via Qt Remote Objects).
// - LogosModuleContext — supplies modules() (typed callers + typed event
// subscriptions for "dependencies") and onContextReady().
// The *Plugin / *Interface classes (Q_PLUGIN_METADATA, initLogos wiring,
// QtRO registration) are generated around it.
class CalcUiCppBackend : public CalcUiCppSimpleSource,
public LogosModuleContext
{
public:
// Slots from calc_ui_cpp.rep — each delegates to calc_module.
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;
};
No Q_OBJECT, no Q_PLUGIN_METADATA, no initLogos, no name()/version() — the universal builder generates all of that. You only declare the .rep slot overrides.
LogosModuleContext also gives this backend typed event subscriptions (modules().dep.on<Event>(...)) and onContextReady() — arm subscriptions there so they're live before the view's first call. calc_module is call-only here, but the ui-typed-backend doc-test shows a .rep PROP fed from a typed module-event subscription registered in onContextReady().
5.2 src/calc_ui_cpp_backend.cpp
#include "calc_ui_cpp_backend.h"
// Generated umbrella: LogosModules (behind modules()) from
// metadata.json#dependencies — typed wrappers + typed event accessors.
#include "logos_sdk.h"
int CalcUiCppBackend::add(int a, int b)
{
return modules().calc_module.add(a, b);
}
int CalcUiCppBackend::multiply(int a, int b)
{
return modules().calc_module.multiply(a, b);
}
int CalcUiCppBackend::factorial(int n)
{
return modules().calc_module.factorial(n);
}
int CalcUiCppBackend::fibonacci(int n)
{
return modules().calc_module.fibonacci(n);
}
QString CalcUiCppBackend::libVersion()
{
// calc_module is itself a universal (std-typed) module, so its typed
// wrapper returns std::string. The .rep slot is QString, so convert.
return QString::fromStdString(modules().calc_module.libVersion());
}
Key points:
- Each slot delegates straight to
calc_moduleviamodules().calc_module.<method>(...)— the generated typed SDK, type-safe with noQVariant. - Slots return values directly; they travel back to the QML replica via Qt Remote Objects.
modules().calc_module.libVersion()returnsstd::stringbecause Part 1'scalc_moduleis also a universal (std-typed) module — itslibVersion()is declaredstd::string. The.repslot isQString libVersion()(the QtRO wire type), so wrap withQString::fromStdString(...). Theintmethods need no conversion:int↔int64_ton the wire.- No
initLogos, no manualLogosModulesconstruction —modules()is wired by the generated plugin before any slot runs.
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 (generated from calc_ui_cpp.rep).
readonly property var backend: logos.module("calc_ui_cpp")
// The ui-host backend connects asynchronously, so the replica isn't
// immediately usable. Track readiness reactively: isViewModuleReady()
// is a Q_INVOKABLE (not a property), so we re-check it on the
// onViewModuleReadyChanged signal and once at startup — never via a
// plain property binding, which would not re-evaluate.
property bool ready: false
Connections {
target: logos
function onViewModuleReadyChanged(moduleName, isReady) {
if (moduleName === "calc_ui_cpp")
root.ready = isReady && root.backend !== null
}
}
Component.onCompleted: {
root.ready = root.backend !== null && logos.isViewModuleReady("calc_ui_cpp")
}
// logos.watch() delivers the result of a replica slot call via callbacks.
// No QtRemoteObjects import needed — the bridge handles it.
function callCalc(method, args) {
if (!root.ready) {
root.errorText = "Backend not ready"
return
}
root.errorText = ""
root.result = "..."
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: "Logos Calculator (C++ backend)"
font.pixelSize: 20
color: "#ffffff"
Layout.alignment: Qt.AlignHCenter
}
// Reactive backend-connection indicator.
Text {
text: root.ready ? "Connected" : "Connecting to backend..."
color: root.ready ? "#56d364" : "#f0883e"
font.pixelSize: 12
Layout.alignment: Qt.AlignHCenter
}
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"
enabled: root.ready
onClicked: root.callCalc("add", [parseInt(inputA.text) || 0, parseInt(inputB.text) || 0])
}
Button {
text: "Multiply"
enabled: root.ready
onClicked: root.callCalc("multiply", [parseInt(inputA.text) || 0, parseInt(inputB.text) || 0])
}
}
RowLayout {
spacing: 12
Layout.fillWidth: true
TextField {
id: inputN
placeholderText: "n"
Layout.preferredWidth: 80
validator: IntValidator { bottom: 0 }
}
Button {
text: "Factorial"
enabled: root.ready
onClicked: root.callCalc("factorial", [parseInt(inputN.text) || 0])
}
Button {
text: "Fibonacci"
enabled: root.ready
onClicked: root.callCalc("fibonacci", [parseInt(inputN.text) || 0])
}
Button {
text: "libcalc version"
enabled: root.ready
onClicked: root.callCalc("libVersion", [])
}
}
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 }
}
}
Key patterns:
logos.module("calc_ui_cpp")— gets the typed replica (auto-synced properties)logos.watch(backend.add(1, 2), ...)— SLOT return value as JS Promise- Readiness: the backend lives in a separate
ui-hostprocess and connects asynchronously, so the replica isn't usable the instant the view loads.logos.isViewModuleReady("calc_ui_cpp")reports the current state and theonViewModuleReadyChangedsignal fires when it changes. BecauseisViewModuleReady()is aQ_INVOKABLEmethod (not a property), don't bind it directly — areadonly property bool ready: logos.isViewModuleReady(...)would never re-evaluate. Use theConnections+Component.onCompletedpattern shown above, and gate the buttons withenabled: root.ready. - The
logosobject is injected by the host at runtime — noQtRemoteObjectsimport needed
Step 7: 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 8: flake.nix
The template already wires everything up. Update the description and point calc_module at your dependency:
{
description = "Calculator C++ UI plugin for Logos - QML view with process-isolated backend for calc_module";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
# Points at your local calc_module checkout. This is a placeholder —
# you lock it to your actual path in the next step with
# `nix flake update --override-input` (see "Lock and build" below).
calc_module.url = "path:/path/to/your/calc_module";
};
outputs = inputs@{ logos-module-builder, calc_module, ... }:
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 placeholder path:/path/to/your/calc_module is not meant to be edited by hand — Nix won't let a flake.nix input use a relative path like ../logos-calc-module (it's evaluated from a sandboxed copy, so .. escapes it). Instead you point it at your real checkout once via --override-input in the next step, which records the resolved absolute path in flake.lock. After that, plain nix run / nix build use the locked path with no override needed.
path:(used here) — a local directory on disk. Best for developingcalc_moduleand its UI side by side, no network.github:— fetchescalc_modulefrom a remote repo instead (for CI, or once it's published to its own repo), e.g.calc_module.url = "github:your-org/your-calc-module";.
Important: Whichever URL scheme you use,
calc_modulemust be built with its shared library (.soon Linux,.dylibon macOS) present inlib/. If it's missing, the nix build will fail with linker errors. 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 9: Build and Run
First, make sure your local calc_module is built and its shared library is present in lib/ (see Part 1, Step 1.5):
9.1 Ensure calc_module is built
ls ../logos-calc-module/lib/libcalc.so # Linux
ls ../logos-calc-module/lib/libcalc.dylib # macOS
If the file is missing, build it first (as covered in Part 1, Step 1.5):
cd ../logos-calc-module/lib
gcc -shared -fPIC -o libcalc.so libcalc.c # Linux
# gcc -shared -fPIC -o libcalc.dylib libcalc.c # macOS
cd ../../logos-calc-ui-cpp
9.2 Lock and build
Stage your files, then lock calc_module to your local Part 1 checkout. The --override-input resolves ../logos-calc-module to an absolute path and records it in flake.lock, replacing the placeholder from flake.nix:
git add -A
nix flake update --override-input calc_module path:../logos-calc-module
git add flake.lock
Now that the lock pins the real path, plain nix run works — no override needed on subsequent commands:
nix run
9.3 Launch and verify the UI
Launch the app and confirm the view loads with all of its controls. The backend runs in a separate ui-host process; clicking Add sends the call over Qt Remote Objects and the result comes back through logos.watch().
nix run .
The result 8 comes from calc_module.add(3, 5) executed in the C++ backend — proof the full path (QML replica → Qt Remote Objects → ui-host backend → typed SDK → calc_module) works end to end.
Step 10: 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: src/qml/):
DEV_QML_PATH=$PWD/src/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/src/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 11: How the Pieces Connect
nix build→ generates the*Plugin/*Interfaceglue around yourCalcUiCppBackend, 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 - The generated plugin calls
initLogos()→ wiresmodules()andonContextReady()→setBackend(<your CalcUiCppBackend>)→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 → your backend'sadd()runsmodules().calc_module.add(1, 2)→ returns result
Step 12: UI Integration Tests
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.
Tests connect to the QML inspector inside logos-standalone-app and can find elements, click buttons, verify text, and take screenshots.
12.1 Create a test file
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(["Logos Calculator (C++ backend)"]);
},
{ timeout: 15000, interval: 500, description: "UI to load" },
);
});
test("calc_ui_cpp: operation buttons visible", async (app) => {
await app.expectTexts(["Add", "Multiply", "Factorial", "Fibonacci"]);
});
run();
12.2 Run the tests
git add tests/
# Hermetic CI test
nix build .#integration-test -L
The integration-test output launches logos-standalone-app with QT_QPA_PLATFORM=offscreen (no display needed), connects to the QML inspector, and runs all .mjs files in tests/.
To run tests interactively (against an already-running app):
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
You declare each pattern in the .rep and implement it in your *Backend (which derives the generated SimpleSource + LogosModuleContext). There is no hand-written plugin — the *Plugin/*Interface glue is generated.
| Pattern | .rep declaration | Backend C++ (CalcUiCppBackend) |
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 from SimpleSource) |
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") |
| Event-fed PROP | PROP(QString last READONLY) |
onContextReady(): modules().dep.on<Event>([this](...){ setLast(...); }) |
backend.last (auto-syncs, no polling) |
The last row uses the LogosModuleContext surface — typed modules() callers and event subscriptions armed in onContextReady(). calc_module is call-only here; see the ui-typed-backend doc-test for a worked event-driven PROP.
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 the design system step. Browse components in the storybook (
cd repos/logos-design-system && nix run); file issues atlogos-co/logos-design-system. - See logos-package-manager-ui for a production example

