Skip to content

Latest commit

 

History

History
929 lines (716 loc) · 46.9 KB

File metadata and controls

929 lines (716 loc) · 46.9 KB

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 .rep file exercising the full QtRO surface, not just slots: value slots and a void slot, PROPs of different types (QString, int) and both modes (READONLY and READWRITE), and a SIGNAL — the one Qt-typed contract you author
  • A C++ *Backend class that derives the generated SimpleSource (implements the .rep) and LogosUiPluginContext (gives modules() Qt-typed callers, event subscriptions, and onContextReady())
  • 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, a Connections block for the signal, and a label fed by a typed calc_module event 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

Prerequisites

  • Completed Part 1 — you have a working calc_module with the shared library built (.so on Linux, .dylib on macOS in logos-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)    │
  │     : 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 implements
  • CalcUiCppReplica — typed replica the QML view uses
  • calc_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 — the Q_OBJECT plugin with Q_PLUGIN_METADATA, initLogos(LogosAPI*), and the setBackend() / 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.

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/0.2.0#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 — 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.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 *Backend class, and the *Plugin/*Interface glue is generated. Without this key, the builder expects the classic hand-written initLogos(LogosAPI*) plugin.
  • "codegen": { "rep": "src/calc_ui_cpp.rep" } — names your view contract. (backend_class / backend_header are also overridable, defaulting to CalcUiCppBackend / 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 via modules()

Step 3: The .rep File

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.hCalcUiCppSimpleSource with virtual slots your *Backend overrides, a set<Prop>(...) setter + change signal for each PROP (setVersionEvent, setComputeCount, setMemory), and the computed(...) signal you emit
  • rep_calc_ui_cpp_replica.hCalcUiCppReplica with typed methods, the auto-synced versionEvent / computeCount / memory properties, and the computed signal the QML view reads, writes, and connects to

One contract, four kinds of surface — slots are only the first:

  • SLOT return values arrive as QRemoteObjectPendingReplylogos.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), and memory (int, READWRITE — QML assigns backend.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 a Connections block.

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:

  1. Run repc to generate the source/replica headers
  2. Generate the *Plugin / *Interface wrapper around your *Backend (because metadata.json sets "interface": "universal")
  3. Build a separate calc_ui_cpp_replica_factory shared library

Step 5: C++ Backend

Now write the backend — the only C++ you author. It's a single class that derives:

  • CalcUiCppSimpleSource — generated by repc from your .rep; you override its slots. The QML replica receives each return value via Qt Remote Objects.
  • LogosUiPluginContext — gives modules() (Qt-typed callers + event subscriptions for your dependencies) and onContextReady(). 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.

5.1 src/calc_ui_cpp_backend.h

#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 the SimpleSource. The backend calls setComputeCount(...) (and could call setMemory(...)); memory is READWRITE, so QML can write it too.
  • SIGNALcomputed(...) is declared on the SimpleSource, so the backend just emits it.
  • Event subscriptionsLogosUiPluginContext gives typed modules().dep.on<Event>(...) and the onContextReady() hook. onContextReady() subscribes to calc_module's versionReady event and pipes the payload into the versionEvent PROP, which Qt Remote Objects auto-syncs to the view. Arm subscriptions in onContextReady() (not the constructor) so they're live the moment the backend is wired.

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)
{
    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_module via modules().calc_module.<method>(...) — the generated typed SDK, type-safe with no QVariant — then calls record() 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() returns QString even though Part 1's calc_module declares it std::string. A UI plugin is Qt-typed (api-style qt), so the generated modules().<dep> wrapper exposes Qt types (QString, int, ...) that match the .rep slots 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 reads backend.computeCount with no polling), while emit computed(...) fires a SIGNAL the view catches in a Connections block. Both are independent of the slot's logos.watch() return value. The READWRITE memory PROP is the fourth path — there the view writes and the value syncs back.
  • Event subscription vs. method call. announceVersion() makes calc_module emit; onContextReady() subscribes to that emission. The callback is Qt-typed (const QString&) and setVersionEvent(...) is the generated PROP setter — the value reaches QML with no polling and no return value to await. Arm the subscription in onContextReady(), never the constructor: modules() isn't wired until the framework calls it.
  • No initLogos, no manual LogosModules construction — modules() is wired by the generated plugin before any slot runs or any event arrives.

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: ""

    // 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.computeCount is read directly in a binding. The backend bumps it via setComputeCount(...) after each calculation, and Qt Remote Objects auto-syncs it — the label re-evaluates with no polling.
  • READWRITE PROP: backend.memory is 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's computed push. No return value, no property — a one-shot event the view reacts to.
  • Event-fed PROP: backend.versionEvent is read directly — no logos.watch(), no polling. The "Announce version" button calls the void announceVersion() slot (which makes calc_module emit versionReady); 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-host process and connects asynchronously, so the replica isn't usable the instant the view loads. logos.isViewModuleReady("calc_ui_cpp") reports the current state and the onViewModuleReadyChanged signal fires when it changes. Because isViewModuleReady() is a Q_INVOKABLE method (not a property), don't bind it directly — a readonly property bool ready: logos.isViewModuleReady(...) would never re-evaluate. Use the Connections + Component.onCompleted pattern shown above, and gate the buttons with enabled: root.ready.
  • The logos object is injected by the host at runtime — no QtRemoteObjects import 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, radiusLarge
  • Theme.typography.*pageTitleText (36), titleText (30), panelTitleText (24), subtitleText (16), primaryText (14), secondaryText (12); weightRegular / weightMedium / weightBold; publicSans
  • Logos.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/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 developing calc_module and its UI side by side, no network.
  • github: — fetches calc_module from 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_module must be built with its shared library (.so on Linux, .dylib on macOS) present in lib/. 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 .

Operation buttons visible

Result of 3 + 5 shows 8

Subscribed event payload reaches the view

Every surface the .rep declares is now proven end to end from one click:

  • SLOT return value — the result 8 comes from calc_module.add(3, 5), the call-and-return path (QML replica → Qt Remote Objects → ui-host backend → typed SDK → calc_module), delivered via logos.watch().
  • READONLY PROP (slot-driven)Computations: 1 is the computeCount PROP: the same add call ran setComputeCount(...) in the backend's record(), and Qt Remote Objects synced it to the view with no polling.
  • SIGNALLast op (signal): add = 8 is the computed signal the backend emitted; the QML Connections block caught it. It carried the op name and the result, neither of which is a property or a return value.
  • READWRITE PROPMemory: 0Memory: 8 proves the write direction: the Store button assigned backend.memory = 8 in 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 PROPVersion event: 1.0.0 is the event path: clicking Announce version called calc_module.libVersionNotify(), which emitted versionReady("1.0.0"); the backend's modules().calc_module.onVersionReady(...) subscription — armed in onContextReady() — caught it and wrote the versionEvent PROP, 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.

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_PATH only 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 run is 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_PATH is honored by logos-standalone-app. See repos/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

  1. nix build → generates the *Plugin/*Interface glue around your CalcUiCppBackend, compiles the C++ plugin + replica factory, bundles QML view
  2. nix run → launches logos-standalone-app which:
    • Loads calc_module (dependency)
    • Spawns a ui-host child process with calc_ui_cpp_plugin.so
    • The generated plugin calls initLogos() → wires modules() and onContextReady()setBackend(<your CalcUiCppBackend>)enableRemoting(host)
    • Backend is now accessible over a local socket
  3. Host app loads calc_ui_cpp_replica_factory.dylib → creates a typed replica
  4. QML gets the replica via logos.module("calc_ui_cpp")
  5. backend.add(1, 2) → Qt Remote Objects sends call to ui-host → your backend's add() runs modules().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 + 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.

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, unlike SLOT/PROP/SIGNAL) — the factory registers it under the Logos.<ModuleName> QML URI, so import Logos.CalcUiCpp then CalcUiCpp.Busy works in bindings
  • Use logos.model() for list views backed by a QAbstractItemModel* Q_PROPERTY — the Model row above, the one .rep pattern this tutorial doesn't build
  • Package as .lgx for 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 at logos-co/logos-design-system.
  • See logos-package-manager-ui for a production example