Files
logos-tutorial/tutorial-cpp-ui-app.md
T
2026-05-28 12:49:58 -03:00

24 KiB

Tutorial Part 3: Building a C++ UI Module (Process-Isolated)

This is Part 3 of the Logos module tutorial series. In Part 2 you built a QML-only UI plugin. Now you'll build a ui_qml module with a C++ backend — the backend runs in a separate ui-host process while the QML view loads in the host app (basecamp / standalone).

What you'll build: A calc_ui_cpp module with:

  • A .rep file defining the remote interface (slots + properties)
  • A C++ backend plugin that inherits from the generated SimpleSource base class
  • A QML view that calls the backend via a typed replica using logos.watch()
  • Process isolation: backend crashes can't bring down the host app

Why C++ backend over QML-only?

QML-only (Part 2) C++ backend (Part 3)
Compilation None CMake + Qt
Process isolation No (QML runs in-process) Yes (C++ in separate ui-host process)
Backend calls logos.callModule() / logos.callModuleAsync() to other modules LogosModules typed SDK in C++
Type safety Args travel as QVariant C++ types preserved
QML ↔ backend Direct bridge Qt Remote Objects (typed replica)
.rep file Not needed Required — defines the remote interface

Prerequisites:

  • Completed Part 1 — you have a working calc_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)
  ┌──────────┼──────────────────────────────────┐
  │          ▼                                  │
  │   CalcUiCppPlugin (backend)                 │
  │     : CalcUiCppSimpleSource                 │
  │     : CalcUiCppViewPluginBase               │
  │     int add(int a, int b) {                 │
  │       return m_logos->calc_module.add(a,b); │
  │     }                                       │
  │          │                                  │
  │          │  LogosModules typed SDK           │
  │          ▼                                  │
  │   calc_module (loaded in ui-host)           │
  └─────────────────────────────────────────────┘

The .rep file declares the interface. At build time, Qt's repc compiler generates:

  • CalcUiCppSimpleSource — base class the backend inherits from
  • CalcUiCppReplica — typed replica the QML view uses
  • calc_ui_cpp_replica_factory — separate plugin that the host loads to create typed replicas

Step 1: Scaffold

mkdir logos-calc-ui-cpp && cd logos-calc-ui-cpp
nix flake init -t github:logos-co/logos-module-builder/tutorial-v2#ui-qml-backend
git init && git add -A

This creates the template. We'll customize it for our calculator.


Step 2: metadata.json

{
  "name": "calc_ui_cpp",
  "version": "1.0.0",
  "type": "ui_qml",
  "category": "tools",
  "description": "Calculator C++ UI — QML view with process-isolated backend",
  "main": "calc_ui_cpp_plugin",
  "view": "qml/Main.qml",
  "icon": "icons/calc.png",
  "dependencies": ["calc_module"],

  "nix": {
    "packages": { "build": [], "runtime": [] },
    "external_libraries": [],
    "cmake": { "find_packages": [], "extra_sources": [] }
  }
}

Key fields:

  • "type": "ui_qml" — tells the builder this is a QML view module
  • "main": "calc_ui_cpp_plugin" — the backend Qt plugin library (without extension)
  • "view": "qml/Main.qml" — the QML entry point
  • "dependencies": ["calc_module"] — core modules the backend calls

Step 3: The .rep File

Create src/calc_ui_cpp.rep:

class CalcUiCpp
{
    PROP(QString status READWRITE)

    SLOT(int add(int a, int b))
    SLOT(int multiply(int a, int b))
    SLOT(int factorial(int n))
    SLOT(int fibonacci(int n))
    SLOT(QString libVersion())
}

This is the single source of truth for the remote interface. repc generates:

  • rep_calc_ui_cpp_source.hCalcUiCppSimpleSource with virtual slots the backend overrides
  • rep_calc_ui_cpp_replica.hCalcUiCppReplica with typed methods and auto-synced properties

PROP values auto-sync from backend to QML replica. SLOT return values are delivered as QRemoteObjectPendingReply — use logos.watch() in QML to get them as JS Promises.


Step 3.1: Update the interface header

The scaffolded template may create an interface file like src/ui_example_interface.h. Rename it to match this tutorial and make sure the class/IID names are updated, or the plugin metadata wiring will break.

# If your scaffold created ui_example files, rename the interface header:
mv src/ui_example_interface.h src/calc_ui_cpp_interface.h

Set src/calc_ui_cpp_interface.h to:

#pragma once

#include <QObject>
#include <QString>
#include "interface.h"

class CalcUiCppInterface : public PluginInterface
{
public:
    virtual ~CalcUiCppInterface() = default;
};

#define CalcUiCppInterface_iid "org.logos.CalcUiCppInterface"
Q_DECLARE_INTERFACE(CalcUiCppInterface, CalcUiCppInterface_iid)

Your plugin header should then include calc_ui_cpp_interface.h and use:

  • Q_PLUGIN_METADATA(IID CalcUiCppInterface_iid FILE "metadata.json")
  • Q_INTERFACES(CalcUiCppInterface)

If the interface filename or IID symbol doesn't match, you'll typically get build errors (missing header/symbol) or plugin-load failures at runtime.


Step 4: CMakeLists.txt

cmake_minimum_required(VERSION 3.14)
project(CalcUiCppPlugin LANGUAGES CXX)

if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
    include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
else()
    message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
endif()

logos_module(
    NAME calc_ui_cpp
    REP_FILE src/calc_ui_cpp.rep
    SOURCES
        src/calc_ui_cpp_interface.h
        src/calc_ui_cpp_plugin.h
        src/calc_ui_cpp_plugin.cpp
)

REP_FILE tells logos_module() to:

  1. Run repc to generate source/replica headers
  2. Generate LogosViewPluginBase (typed remoting base class)
  3. Build a separate calc_ui_cpp_replica_factory shared library

Step 5: C++ Backend Plugin

src/calc_ui_cpp_plugin.h

#pragma once

#include <QString>
#include <QVariantList>
#include "calc_ui_cpp_interface.h"
#include "LogosViewPluginBase.h"
#include "rep_calc_ui_cpp_source.h"

class LogosAPI;
class LogosModules;

class CalcUiCppPlugin : public CalcUiCppSimpleSource,
                        public CalcUiCppInterface,
                        public CalcUiCppViewPluginBase
{
    Q_OBJECT
    Q_PLUGIN_METADATA(IID CalcUiCppInterface_iid FILE "metadata.json")
    Q_INTERFACES(CalcUiCppInterface)

public:
    explicit CalcUiCppPlugin(QObject* parent = nullptr);
    ~CalcUiCppPlugin() override;

    QString name()    const override { return "calc_ui_cpp"; }
    QString version() const override { return "1.0.0"; }

    Q_INVOKABLE void initLogos(LogosAPI* api);

    // Slots from .rep — override the generated virtuals
    int add(int a, int b) override;
    int multiply(int a, int b) override;
    int factorial(int n) override;
    int fibonacci(int n) override;
    QString libVersion() override;

signals:
    void eventResponse(const QString& eventName, const QVariantList& args);

private:
    LogosAPI* m_logosAPI = nullptr;
    LogosModules* m_logos = nullptr;
};

Three base classes:

  • CalcUiCppSimpleSource — generated from .rep, provides the typed source for Qt Remote Objects
  • CalcUiCppInterface — standard Logos plugin interface (name(), version())
  • CalcUiCppViewPluginBase — generated, provides setBackend() and enableRemoting()

src/calc_ui_cpp_plugin.cpp

#include "calc_ui_cpp_plugin.h"
#include "logos_api.h"
#include "logos_sdk.h"

CalcUiCppPlugin::CalcUiCppPlugin(QObject* parent)
    : CalcUiCppSimpleSource(parent) {}

CalcUiCppPlugin::~CalcUiCppPlugin() { delete m_logos; }

void CalcUiCppPlugin::initLogos(LogosAPI* api)
{
    m_logosAPI = api;
    m_logos = new LogosModules(api);
    // Register this object as the Remote Objects source
    setBackend(this);
}

int CalcUiCppPlugin::add(int a, int b)
{
    return m_logos->calc_module.add(a, b);
}

int CalcUiCppPlugin::multiply(int a, int b)
{
    return m_logos->calc_module.multiply(a, b);
}

int CalcUiCppPlugin::factorial(int n)
{
    return m_logos->calc_module.factorial(n);
}

int CalcUiCppPlugin::fibonacci(int n)
{
    return m_logos->calc_module.fibonacci(n);
}

QString CalcUiCppPlugin::libVersion()
{
    return m_logos->calc_module.libVersion();
}

Key points:

  • Constructor calls CalcUiCppSimpleSource(parent) — not QObject(parent)
  • initLogos() calls setBackend(this) to register with the Remote Objects host
  • Slots return values directly — they travel back to the QML replica via Qt Remote Objects
  • m_logos->calc_module.add(a, b) uses the generated typed SDK (type-safe, no QVariant)

Step 6: QML View

Create src/qml/Main.qml:

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts


Item {
    id: root

    property string result: ""
    property string errorText: ""

    // Typed replica of the backend running in ui-host
    readonly property var backend: logos.module("calc_ui_cpp")

    // "status" property from the .rep — auto-synced via Qt Remote Objects
    readonly property string status: backend ? backend.status : ""

    function callCalc(method, args) {
        if (!backend) {
            root.errorText = "Backend not available"
            return
        }
        root.errorText = ""
        root.result = "..."
        // logos.watch() wraps the pending reply in a JS Promise
        logos.watch(backend[method].apply(backend, args),
            function(value) { root.result = String(value) },
            function(error) { root.errorText = String(error) }
        )
    }

    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 24
        spacing: 16

        Text {
            text: "Calculator (C++ backend)"
            font.pixelSize: 20
            color: "#ffffff"
        }

        RowLayout {
            spacing: 12

            TextField {
                id: inputA; placeholderText: "a"
                Layout.preferredWidth: 80
                validator: IntValidator {}
            }
            TextField {
                id: inputB; placeholderText: "b"
                Layout.preferredWidth: 80
                validator: IntValidator {}
            }
            Button {
                text: "Add"
                onClicked: root.callCalc("add", [parseInt(inputA.text) || 0,
                                                  parseInt(inputB.text) || 0])
            }
            Button {
                text: "Multiply"
                onClicked: root.callCalc("multiply", [parseInt(inputA.text) || 0,
                                                       parseInt(inputB.text) || 0])
            }
        }

        Rectangle {
            Layout.fillWidth: true; height: 56
            color: root.errorText ? "#3d1a1a" : "#1a2d1a"
            radius: 8
            Text {
                anchors.centerIn: parent
                text: root.errorText || root.result || "Press a button"
                color: root.errorText ? "#f85149" : "#56d364"
                font.pixelSize: 15
            }
        }

        Text {
            text: "Backend status: " + root.status
            color: "#8b949e"; font.pixelSize: 13
        }
    }
}

Key patterns:

  • logos.module("calc_ui_cpp") — gets the typed replica (auto-synced properties)
  • backend.status — PROP from .rep, updates automatically
  • logos.watch(backend.add(1, 2), ...) — SLOT return value as JS Promise
  • ``— required forlogos.watch()

Step 6.5: Use the Logos Design System in your QML

The QML you load above runs inside the host (logos-basecamp / logos-standalone-app), which already has logos-design-system on the QML import path. Use its themed components rather than rolling your own visuals — your module gets the polished look automatically as the design system evolves.

import Logos.Theme
import Logos.Controls
import Logos.Icons        // optional shared icon assets

LogosButton {
    text: qsTr("Add")
    onClicked: root.callCalc("add", [parseInt(inputA.text) || 0,
                                     parseInt(inputB.text) || 0])
}

LogosTextField {
    id: inputA
    placeholderText: qsTr("a")
}

Rectangle {
    color: Theme.palette.backgroundSecondary
    radius: Theme.spacing.radiusSmall
    LogosText { text: qsTr("Result"); color: Theme.palette.text }
}

Discover what's available by running the storybook:

cd repos/logos-design-system && nix run

The sidebar splits components into:

  • Controls — designed per Figma, production-ready (LogosButton, LogosBadge, LogosCheckbox, LogosComboBox, LogosIconButton, LogosPaginator, LogosSearchBar, LogosTabBar, LogosTable, LogosText, LogosTextField, LogosToolTip, …).
  • Controls (not designed) — placeholders with stable APIs but unstyled visuals (LogosDialog, LogosDrawer, LogosScrollView, LogosSpinner, LogosTextArea, LogosSwitch, …). You can ship with them; they'll get the polished look applied later without you having to change your QML.

Theme tokens (use these instead of hex literals or magic font sizes):

  • Theme.palette.*background, backgroundSecondary, surface, text, textSecondary, border, primary, success, warning, error, info, hover, pressed, …
  • Theme.spacing.*tiny, small, medium, large, xlarge, xxlarge, radiusSmall, radiusMedium, 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 7: flake.nix

{
  description = "Calculator C++ UI plugin — QML view with process-isolated backend";

  inputs = {
    logos-module-builder.url = "github:logos-co/logos-module-builder/tutorial-v2";

    # Option A: point to a remote repo (for CI or when calc_module is published)
    calc_module.url = "github:logos-co/logos-tutorial/tutorial-v2?dir=logos-calc-module";

    # Option B: point to your local checkout (for local development)
    # calc_module.url = "path:../logos-calc-module";
  };

  outputs = inputs@{ logos-module-builder, ... }:
    logos-module-builder.lib.mkLogosQmlModule {
      src = ./.;
      configFile = ./metadata.json;
      flakeInputs = inputs;
    };
}

The calc_module input attribute name must match the dependency name in metadata.json. The URL can be:

  • github: — fetches from a remote GitHub repo. Use for CI or when calc_module is published.
  • path: — points to a local directory on disk (e.g., path:../logos-calc-module). Use during local development.

Important: calc_module bundles a small C library (libcalc) compiled from source during its own Nix build — there's no prebuilt .so/.dylib to stage. Building calc_module (which happens automatically when this app pulls it in) compiles it for you. See Part 1, Step 1.5.

mkLogosQmlModule handles everything: compiles the C++ backend (because main is set), bundles the QML view, generates LGX packages, and wires up nix run.


Step 8: Build and Run

First, make sure your local calc_module builds cleanly — its C library is compiled from source during the Nix build (from lib/libcalc.c via lib/Makefile), so there's nothing to pre-build by hand (see Part 1, Step 1.5):

cd ../logos-calc-module && git add -A && nix build && cd -

Then build and run. Choose the approach that matches your flake.nix setup:

git add -A

# If flake.nix uses path:../logos-calc-module — just run directly:
nix run

# If flake.nix uses github: — override to use your local checkout:
nix run --override-input calc_module path:../logos-calc-module

# Or from the workspace:
./scripts/ws run logos-calc-ui-cpp --local logos-calc-ui-cpp logos-calc-module

Live reloading QML with DEV_QML_PATH

For QML iteration, point DEV_QML_PATH at the directory that contains your view entry's basename (from metadata.json "view"). This tutorial sets "view": "qml/Main.qml", so the directory must contain Main.qml (here: qml/ at the repo root):

DEV_QML_PATH=$PWD/qml nix run .

When DEV_QML_PATH is set, logos-standalone-app loads QML from your source tree at runtime instead of the installed copy — so edits to Main.qml (and any QML under that tree) are picked up on the next relaunch without you having to re-sync files.

Important — what this does not skip. nix run always re-evaluates the flake and rehashes the source tree before launching. By default src = ./. includes every tracked file, including *.qml — so:

  • Any source change, including QML edits, rebuilds the plugin before the app starts. DEV_QML_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/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 9: How the Pieces Connect

  1. nix build → 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
    • ui-host calls initLogos()setBackend(this)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 → backend runs → returns result
  6. backend.status auto-syncs whenever the backend calls setStatus(...)

Step 10: UI Integration Tests (Optional)

Add automated UI tests using the logos-qt-mcp test framework. Just create .mjs files in tests/ and logos-module-builder auto-wires nix build .#integration-test.

Create tests/ui-tests.mjs:

import { resolve } from "node:path";

// CI sets LOGOS_QT_MCP automatically; for interactive use: nix build .#test-framework -o result-mcp
const root =
  process.env.LOGOS_QT_MCP ||
  new URL("../result-mcp", import.meta.url).pathname;
const { test, run } = await import(
  resolve(root, "test-framework/framework.mjs")
);

test("calc_ui_cpp: loads and shows title", async (app) => {
  await app.waitFor(
    async () => {
      await app.expectTexts(["UI Example (C++ backend)"]);
    },
    { timeout: 15000, interval: 500, description: "UI to load" },
  );
});

test("calc_ui_cpp: shows connection status", async (app) => {
  await app.expectTexts(["Connecting to backend..."]);
});

test("calc_ui_cpp: add button visible", async (app) => {
  await app.expectTexts(["Add"]);
});

run();
git add tests/

# Hermetic CI test
nix build .#integration-test -L

# Interactive
nix build .#test-framework -o result-mcp
nix run .                     # app with inspector on :3768
node tests/ui-tests.mjs       # in another terminal

Comparison: .rep Interface Patterns

Pattern .rep declaration Backend C++ QML usage
Return value SLOT(int add(int a, int b)) int add(...) override { return ...; } logos.watch(backend.add(1,2), cb)
Property PROP(QString status READWRITE) setStatus("Ready") (inherited) backend.status (auto-syncs)
Signal SIGNAL(errorOccurred(QString msg)) emit errorOccurred("fail") Connections { target: backend; function onErrorOccurred(msg) {...} }
Model (use Q_PROPERTY on backend) Q_PROPERTY(QAbstractItemModel* items ...) logos.model("calc_ui_cpp", "items")

Next Steps

  • Add more .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 Step 6.5. Browse components in the storybook (cd repos/logos-design-system && nix run); file issues at logos-co/logos-design-system (bugs on designed components, feature type for new components / variants / theme tokens).
  • See logos-package-manager-ui for a production example