Files
logos-tutorial/outputs/tutorial-cpp-ui-app.md
Dario Gabriel Lipicar 492e4d3f61 tutorial(cpp-ui): derive module NAME from metadata.json in CMake
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).
2026-06-13 08:59:52 -03:00

34 KiB
Raw Permalink Blame History

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 defining the remote interface (slots) — the one Qt-typed contract you author
  • A C++ *Backend class that derives the generated SimpleSource (implements the .rep) and LogosModuleContext (gives modules() typed callers, event subscriptions, and onContextReady())
  • 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_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)    │
  │     : 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 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 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 *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
{
    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.hCalcUiCppSimpleSource with virtual slots your *Backend overrides
  • rep_calc_ui_cpp_replica.hCalcUiCppReplica with 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:

  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.
  • LogosModuleContext — gives modules() (typed callers + event subscriptions for your dependencies) and onContextReady(), 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_module via modules().calc_module.<method>(...) — the generated typed SDK, type-safe with no QVariant.
  • Slots return values directly; they travel back to the QML replica via Qt Remote Objects.
  • modules().calc_module.libVersion() returns std::string because Part 1's calc_module is also a universal (std-typed) module — its libVersion() is declared std::string. The .rep slot is QString libVersion() (the QtRO wire type), so wrap with QString::fromStdString(...). The int methods need no conversion: intint64_t on the wire.
  • No initLogos, no manual LogosModules construction — 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-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";

    # 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

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_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 + 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 .rep properties/signals for richer UI state
  • Use logos.model() for list views backed by QAbstractItemModel
  • 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