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 exercising the full QtRO surface, not just slots: value slots and a void slot,PROPs of different types (QString,int) and both modes (READONLYandREADWRITE), and aSIGNAL— the one Qt-typed contract you author - A C++
*Backendclass that derives the generatedSimpleSource(implements the.rep) andLogosUiPluginContext(givesmodules()Qt-typed callers, event subscriptions, andonContextReady()) - A QML view that drives each surface:
logos.watch()for slot replies, plain property reads for auto-synced PROPs, a property write for the READWRITE memory register, aConnectionsblock for the signal, and a label fed by a typedcalc_moduleevent subscription - 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 |
- 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
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) │
│ : LogosUiPluginContext (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 LogosUiPluginContext. A UI plugin is a view, not a module, so the context carries only the dependency surface: 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). The dep wrappers are Qt-typed (QString, int, ...) to match the .rep slots — no std<->Qt conversions in the view.
The .rep is more than a list of slots, and this tutorial exercises the whole of it: value slots and a void slot, PROPs of different types and both modes (a slot-driven READONLY counter, a READWRITE memory register QML writes back, an event-fed READONLY string), and a SIGNAL the backend pushes to the view. The calculator slots call calc_module; record() feeds the PROP + SIGNAL after each call; and onContextReady() subscribes to calc_module's versionReady event to feed the auto-syncing event PROP.
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/0.2.0#ui-qml-backendThis 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.cppRemove 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 -AReplace 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 — a PNG that is exactly 256×256, the only size LGX packaging accepts (displayed in the logos-basecamp sidebar when the module is loaded):
mkdir -p icons
# Copy any PNG here — or generate a 256×256 placeholder:
echo "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEAAQMAAABmvDolAAAABlBMVEUuzHEuzHEVOa2oAAAAH0lEQVR42u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAvg0hAAABYOSdlwAAAABJRU5ErkJggg==" | base64 -d > icons/calc.pngKey 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()
Create src/calc_ui_cpp.rep:
class CalcUiCpp
{
// ── SLOTs — call-and-return; each reply reaches QML via logos.watch() ──
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())
// Void slot — fire-and-forget. Asks calc_module to (re-)announce
// its version as a `versionReady` event; there's no return value
// to await, the answer comes back through the PROP below.
SLOT(void announceVersion())
// ── PROPs — auto-synced backend → every QML replica, no polling ──
// QString, event-fed: the typed `versionReady` subscription the
// backend arms in onContextReady() writes it. Starts empty.
PROP(QString versionEvent="" READONLY)
// int, slot-driven: the backend bumps it after each calculation,
// so the view shows a live tally without ever polling.
PROP(int computeCount=0 READONLY)
// int, READWRITE: a memory register the QML view both *reads* and
// *writes* (Store / Clear buttons), and the backend may set too.
// A write round-trips QML → replica → source → back to every replica.
PROP(int memory=0 READWRITE)
// ── SIGNAL — backend → view push, distinct from a return value ──
// Emitted after each calculation. QML catches it with a
// Connections block (not logos.watch(), not a PROP read).
SIGNAL(computed(QString op, int result))
}
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, int) because that's the Qt Remote Objects wire contract. repc generates:
rep_calc_ui_cpp_source.h—CalcUiCppSimpleSourcewith virtual slots your*Backendoverrides, aset<Prop>(...)setter + change signal for each PROP (setVersionEvent,setComputeCount,setMemory), and thecomputed(...)signal youemitrep_calc_ui_cpp_replica.h—CalcUiCppReplicawith typed methods, the auto-syncedversionEvent/computeCount/memoryproperties, and thecomputedsignal the QML view reads, writes, and connects to
One contract, four kinds of surface — slots are only the first:
- SLOT return values arrive as
QRemoteObjectPendingReply—logos.watch()turns them into JS Promises in QML.add/multiply/… return values;announceVersion()is a void slot (fire-and-forget, no reply to watch). - PROP auto-syncs from the backend to every QML replica with no polling. We use three, of two types and two modes:
versionEvent(QString, READONLY, fed by a module-event subscription in Step 5),computeCount(int, READONLY, bumped by each slot), andmemory(int, READWRITE — QML assignsbackend.memory = …and the new value round-trips through the backend source and back to the view). - SIGNAL is a backend → view push that is neither a return value nor a synced property:
computed(op, result)fires after each calculation and the QML view catches it with aConnectionsblock.
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
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.LogosUiPluginContext— givesmodules()(Qt-typed callers + event subscriptions for yourdependencies) andonContextReady(). A UI plugin is a view, not a module, so that is all the context carries.
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.
#pragma once
#include "rep_calc_ui_cpp_source.h"
#include "logos_ui_plugin_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).
// - LogosUiPluginContext — supplies modules() (Qt-typed callers + typed event
// subscriptions for "dependencies") and onContextReady(). A UI plugin is a
// view, not a module, so that is all the context carries.
// The *Plugin / *Interface classes (Q_PLUGIN_METADATA, initLogos wiring,
// QtRO registration) are generated around it.
class CalcUiCppBackend : public CalcUiCppSimpleSource,
public LogosUiPluginContext
{
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;
// Tells calc_module to emit its `versionReady` event.
void announceVersion() override;
// Fires once when ui-host hands the plugin its LogosAPI — the
// typed dependency surface is live, so we arm the event
// subscription here (before the view's first call).
void onContextReady() override;
private:
// Feeds the non-slot surfaces of the .rep after each calculation:
// bumps the computeCount PROP (setComputeCount, generated) and
// emits the `computed` SIGNAL. The READWRITE `memory` PROP is
// driven from QML, so the backend doesn't have to touch it.
void record(const QString& op, int result);
};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 (plus any private helpers of your own, like record).
Beyond the call-and-return slots, the backend drives the rest of the .rep surface:
- PROPs — each gets a generated
set<Prop>()setter on theSimpleSource. The backend callssetComputeCount(...)(and could callsetMemory(...));memoryis READWRITE, so QML can write it too. - SIGNAL —
computed(...)is declared on theSimpleSource, so the backend justemits it. - Event subscriptions —
LogosUiPluginContextgives typedmodules().dep.on<Event>(...)and theonContextReady()hook.onContextReady()subscribes tocalc_module'sversionReadyevent and pipes the payload into theversionEventPROP, which Qt Remote Objects auto-syncs to the view. Arm subscriptions inonContextReady()(not the constructor) so they're live the moment the backend is wired.
#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)
{
int result = modules().calc_module.add(a, b);
record("add", result);
return result;
}
int CalcUiCppBackend::multiply(int a, int b)
{
int result = modules().calc_module.multiply(a, b);
record("multiply", result);
return result;
}
int CalcUiCppBackend::factorial(int n)
{
int result = modules().calc_module.factorial(n);
record("factorial", result);
return result;
}
int CalcUiCppBackend::fibonacci(int n)
{
int result = modules().calc_module.fibonacci(n);
record("fibonacci", result);
return result;
}
QString CalcUiCppBackend::libVersion()
{
// A UI plugin is Qt-typed: modules().calc_module's wrapper returns QString
// (api-style qt), matching the .rep slot — no conversion needed.
return modules().calc_module.libVersion();
}
void CalcUiCppBackend::record(const QString& op, int result)
{
// PROP: bump the slot-driven counter. setComputeCount() is the
// generated setter; Qt Remote Objects syncs the new value to every
// replica, so the view's "Computations" label updates with no polling.
setComputeCount(computeCount() + 1);
// SIGNAL: a backend → view push, distinct from the return value the
// QML side gets via logos.watch(). `computed` is declared on the
// generated SimpleSource, so we just emit it; the typed replica
// re-emits it and the view's Connections block catches it.
emit computed(op, result);
}
void CalcUiCppBackend::announceVersion()
{
// Fire-and-forget call into calc_module: it looks up the library
// version and emits it as a `versionReady` event. We don't read a
// return value here — the event comes back through the subscription
// armed in onContextReady() below.
modules().calc_module.libVersionNotify();
}
void CalcUiCppBackend::onContextReady()
{
// Typed module-event subscription. `versionReady` is calc_module's
// event (Part 1's `logos_events:` block); the generated wrapper
// exposes it as on<Event> + a Qt-typed callback (QString, because a
// UI plugin is api-style qt). Push each payload into the versionEvent
// PROP — Qt Remote Objects then auto-syncs it to the QML replica.
modules().calc_module.onVersionReady([this](const QString& version) {
setVersionEvent(version);
});
}Key points:
- Each value slot delegates straight to
calc_moduleviamodules().calc_module.<method>(...)— the generated typed SDK, type-safe with noQVariant— then callsrecord()to drive the PROP + SIGNAL surfaces before returning. Slot return values travel back to the QML replica via Qt Remote Objects. modules().calc_module.libVersion()returnsQStringeven though Part 1'scalc_moduledeclares itstd::string. A UI plugin is Qt-typed (api-styleqt), so the generatedmodules().<dep>wrapper exposes Qt types (QString,int, ...) that match the.repslots directly — the std<->Qt conversion happens inside the generated wrapper, not in your view code.- PROP vs. SIGNAL vs. return value — three ways to get data to the view.
record()shows two of them side by side:setComputeCount(...)writes a PROP that Qt Remote Objects auto-syncs (the view readsbackend.computeCountwith no polling), whileemit computed(...)fires a SIGNAL the view catches in aConnectionsblock. Both are independent of the slot'slogos.watch()return value. The READWRITEmemoryPROP is the fourth path — there the view writes and the value syncs back. - Event subscription vs. method call.
announceVersion()makescalc_moduleemit;onContextReady()subscribes to that emission. The callback is Qt-typed (const QString&) andsetVersionEvent(...)is the generated PROP setter — the value reaches QML with no polling and no return value to await. Arm the subscription inonContextReady(), never the constructor:modules()isn't wired until the framework calls it. - No
initLogos, no manualLogosModulesconstruction —modules()is wired by the generated plugin before any slot runs or any event arrives.
Create src/qml/Main.qml:
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
property string result: ""
property string errorText: ""
// Last payload from the backend's `computed` SIGNAL (see Connections below).
property string lastSignal: "(none)"
// 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")
}
// SIGNAL from the .rep: the backend emits `computed(op, result)` after
// each calculation. The typed replica re-emits it, so we catch it with
// a Connections block — no logos.watch(), no property read. This is the
// backend → view push path, distinct from the slot return value above.
Connections {
target: root.backend
function onComputed(op, result) {
root.lastSignal = op + " = " + result
}
}
// 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", [])
}
Button {
// Fires the event path: asks calc_module to emit
// versionReady. No logos.watch() — the result comes
// back through the versionEvent PROP, not a return value.
text: "Announce version (event)"
enabled: root.ready
onClicked: root.backend.announceVersion()
}
}
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
}
}
// Slot-driven PROP: bumped by the backend's record() after each
// calculation. A plain property read — auto-syncs, no polling.
Text {
text: "Computations: " + ((root.ready && root.backend) ? root.backend.computeCount : 0)
color: "#cdd6f4"
font.pixelSize: 14
Layout.alignment: Qt.AlignHCenter
}
// SIGNAL payload, captured by the Connections block above.
Text {
text: "Last op (signal): " + root.lastSignal
color: "#94e2d5"
font.pixelSize: 14
Layout.alignment: Qt.AlignHCenter
}
// READWRITE PROP: the memory register. The label *reads*
// backend.memory; the buttons *write* it. A write round-trips
// QML → replica → backend source → back to every replica, so the
// label updates once the new value syncs home.
RowLayout {
spacing: 12
Layout.alignment: Qt.AlignHCenter
Text {
text: "Memory: " + ((root.ready && root.backend) ? root.backend.memory : 0)
color: "#cdd6f4"
font.pixelSize: 14
}
Button {
text: "Store (MS)"
enabled: root.ready
onClicked: root.backend.memory = parseInt(root.result) || 0
}
Button {
text: "Clear (MC)"
enabled: root.ready
onClicked: root.backend.memory = 0
}
}
// Event-fed label: the versionEvent PROP auto-syncs from the
// backend's typed versionReady subscription. No polling — it
// updates the moment calc_module emits.
Text {
readonly property string ev: (root.ready && root.backend) ? root.backend.versionEvent : ""
text: "Version event: " + (ev.length > 0 ? ev : "(none yet)")
color: "#f9e2af"
font.pixelSize: 15
Layout.alignment: Qt.AlignHCenter
}
Item { Layout.fillHeight: true }
}
}Key patterns — one for each surface the .rep exposes:
logos.module("calc_ui_cpp")— gets the typed replica (auto-synced properties, callable slots, connectable signals)- SLOT return value:
logos.watch(backend.add(1, 2), ...)— the reply as a JS Promise. - READONLY PROP (slot-driven):
backend.computeCountis read directly in a binding. The backend bumps it viasetComputeCount(...)after each calculation, and Qt Remote Objects auto-syncs it — the label re-evaluates with no polling. - READWRITE PROP:
backend.memoryis both read (the "Memory:" label) and written (backend.memory = ...in the Store/Clear buttons). Assigning the property on the replica pushes the value to the backend source; it syncs back to every replica, so the label updates once the write lands. - SIGNAL:
Connections { target: root.backend; function onComputed(op, result) { ... } }catches the backend'scomputedpush. No return value, no property — a one-shot event the view reacts to. - Event-fed PROP:
backend.versionEventis read directly — nologos.watch(), no polling. The "Announce version" button calls the voidannounceVersion()slot (which makescalc_moduleemitversionReady); the backend's subscription catches the event and writes the PROP, and Qt Remote Objects pushes the new value straight into this label. This is the event path, distinct from the call-and-return slots above. - 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
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 runThe 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.
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/0.2.0";
# 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.
First, make sure your local calc_module is built and its shared library is present in lib/ (see Part 1, Step 1.5):
ls ../logos-calc-module/lib/libcalc.so # Linux
ls ../logos-calc-module/lib/libcalc.dylib # macOSIf 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-cppStage 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 -Anix flake update --override-input calc_module path:../logos-calc-modulegit add flake.lockNow that the lock pins the real path, plain nix run works — no override needed on subsequent commands:
nix runLaunch 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 .Every surface the .rep declares is now proven end to end from one click:
- SLOT return value — the result
8comes fromcalc_module.add(3, 5), the call-and-return path (QML replica → Qt Remote Objects → ui-host backend → typed SDK →calc_module), delivered vialogos.watch(). - READONLY PROP (slot-driven) —
Computations: 1is thecomputeCountPROP: the sameaddcall ransetComputeCount(...)in the backend'srecord(), and Qt Remote Objects synced it to the view with no polling. - SIGNAL —
Last op (signal): add = 8is thecomputedsignal the backendemitted; the QMLConnectionsblock caught it. It carried the op name and the result, neither of which is a property or a return value. - READWRITE PROP —
Memory: 0→Memory: 8proves the write direction: the Store button assignedbackend.memory = 8in QML, which pushed to the backend source and synced back to the label. Properties aren't read-only mirrors; QML can drive them too. - Event-fed PROP —
Version event: 1.0.0is the event path: clicking Announce version calledcalc_module.libVersionNotify(), which emittedversionReady("1.0.0"); the backend'smodules().calc_module.onVersionReady(...)subscription — armed inonContextReady()— caught it and wrote theversionEventPROP, which Qt Remote Objects synced into the view. The label was(none yet)until the event fired, so seeing the version proves the typed subscription delivered.
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.
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
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.
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();git add tests/# Hermetic CI test
nix build .#integration-test -LThe 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 terminalYou declare each pattern in the .rep and implement it in your *Backend (which derives the generated SimpleSource + LogosUiPluginContext). There is no hand-written plugin — the *Plugin/*Interface glue is generated. Every row but Model is live in this tutorial's .rep (✓), and the UI test in Step 9 drives each one:
| 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) |
| Void slot ✓ | SLOT(void announceVersion()) |
void announceVersion() override { ... } |
backend.announceVersion() (fire-and-forget, no watch) |
| READONLY PROP ✓ | PROP(int computeCount=0 READONLY) |
setComputeCount(computeCount()+1) (inherited setter) |
backend.computeCount (auto-syncs, read-only) |
| READWRITE PROP ✓ | PROP(int memory=0 READWRITE) |
setMemory(...) — or let QML write it |
backend.memory (read) / backend.memory = 8 (write, round-trips) |
| Signal ✓ | SIGNAL(computed(QString op, int result)) |
emit computed("add", 8) |
Connections { target: backend; function onComputed(op, result) {...} } |
| Event-fed PROP ✓ | PROP(QString versionEvent="" READONLY) |
onContextReady(): modules().dep.on<Event>([this](...){ setVersionEvent(...); }) |
backend.versionEvent (auto-syncs, no polling) |
| Model | (use Q_PROPERTY on backend) | Q_PROPERTY(QAbstractItemModel* items ...) |
logos.model("calc_ui_cpp", "items") |
The last live row uses the LogosUiPluginContext surface — Qt-typed modules() callers and event subscriptions armed in onContextReady(): versionEvent is a PROP fed by the modules().calc_module.onVersionReady(...) subscription, and the Announce version button drives it. Model is the one pattern shown but not built here — for a QAbstractItemModel* Q_PROPERTY remoted via logos.model(), see Next Steps.
- This tutorial already exercises slots, PROPs of different types and modes, and a signal — extend them with more state for your own UI
- Add an enum to the
.rep(ENUM Status { Idle, Busy }— no parentheses, unlikeSLOT/PROP/SIGNAL) — the factory registers it under theLogos.<ModuleName>QML URI, soimport Logos.CalcUiCppthenCalcUiCpp.Busyworks in bindings - Use
logos.model()for list views backed by aQAbstractItemModel*Q_PROPERTY — the Model row above, the one.reppattern this tutorial doesn't build - 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


