Skip to main content

Build a Logos C++ UI module

Get started building a ui_qml module with a C++ backend that runs in a separate process.

This guide covers building a module that pairs a QML user interface with a C++ backend plugin. The backend runs in a separate ui-host process while the QML view loads inside the host app (logos-basecamp or logos-standalone-app), so a backend crash cannot bring down the host. This guide is intended for developers who have completed Part 1 and want typed, process-isolated inter-module calls from their UI layer.

Before you start, make sure you have the following:

  • Completed Part 1 — a working calc_module with the shared library built in logos-calc-module/lib/.
  • Nix with flakes enabled.
  • Basic familiarity with QML

What to expect

  • You can scaffold, configure, and build a calc_ui_cpp module with a process-isolated C++ backend.
  • You can call calc_module methods from QML using a typed replica and logos.watch().
  • You will be able to build, run, and live-reload the module using nix run.

Step 1: Scaffold the project

Create a new directory and initialise it from the C++ backend UI template.

  1. Create and enter the project directory:

    mkdir logos-calc-ui-cpp && cd logos-calc-ui-cpp
  2. Initialise from the template:

    nix flake init -t github:logos-co/logos-module-builder/0.2.0#ui-qml-backend
  3. Initialise a Git repository and stage all generated files:

    git init && git add -A
  4. Remove the template's example sources. The scaffolded template includes ui_example files with mismatched class names and IIDs; leaving them causes build errors or plugin-load failures at runtime:

    rm -f src/ui_example.rep src/ui_example_interface.h src/ui_example_plugin.h src/ui_example_plugin.cpp

Step 2: Configure the module metadata

Replace the template metadata.json with your plugin's details.

info

The calc_module.url input attribute name in flake.nix must match the dependency name declared here in "dependencies".

  1. Replace metadata.json with the following:

    {
    "name": "calc_ui_cpp",
    "version": "1.0.0",
    "type": "ui_qml",
    "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"],

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

    Key fields:

    • "type": "ui_qml" — tells the builder this is a QML view module.
    • "main": "calc_ui_cpp_plugin" — the backend Qt plugin library name (without extension).
    • "view": "qml/Main.qml" — the QML entry point.
    • "dependencies": ["calc_module"]core modules the backend calls.
  2. Create the icons 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 64x64 placeholder:
    echo "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAmElEQVR4nO3QMREAIBDAsFeEN3ziCWRkoEP2XmedfX82OkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAO0BN/SiO/PatoIAAAAASUVORK5CYII=" | base64 -d > icons/calc.png

Step 3: Define the remote interface

The .rep file is the single source of truth for the interface between the QML view and the C++ backend. repc compiles it at build time into typed source and replica headers.

  1. 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())
    }

    repc generates two headers from this file:

    • rep_calc_ui_cpp_source.hCalcUiCppSimpleSource with virtual slots the backend overrides.
    • rep_calc_ui_cpp_replica.hCalcUiCppReplica with typed methods the QML view calls.

Step 4: Write the interface header

Create src/calc_ui_cpp_interface.h:

#ifndef CALC_UI_CPP_INTERFACE_H
#define CALC_UI_CPP_INTERFACE_H

#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)

#endif // CALC_UI_CPP_INTERFACE_H

Step 5: Configure the CMake build

Create 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 run repc to generate source/replica headers, generate CalcUiCppViewPluginBase, and build a separate calc_ui_cpp_replica_factory shared library.

Step 6: Write the C++ backend plugin

The backend plugin inherits 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().
  1. Create src/calc_ui_cpp_plugin.h:

    #ifndef CALC_UI_CPP_PLUGIN_H
    #define CALC_UI_CPP_PLUGIN_H

    #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 calc_ui_cpp.rep — return values directly. The QML replica
    // receives QRemoteObjectPendingReply; use logos.watch() in QML to get the value.
    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;
    };

    #endif // CALC_UI_CPP_PLUGIN_H
    info

    If the interface filename or IID symbol here doesn't match the names in src/calc_ui_cpp_interface.h, you will get build errors or plugin-load failures at runtime.

  2. Create 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)
    {
    if (m_logos) return;
    m_logosAPI = api;
    m_logos = new LogosModules(api);

    // Register this object as the Remote Objects source so the QML replica
    // can see its properties and call its slots.
    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();
    }

Step 7: Write the QML view

  1. 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.
    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, with auto-synced properties.
    • logos.watch(backend.add(1, 2), ...) — delivers a SLOT return value as a JS Promise.
    • The logos object is injected by the host at runtime — no QtRemoteObjects import is needed.

Step 7.5: Use the Logos Design System in your QML (Optional)

The QML view runs inside the logos-standalone-app host app, which already has logos-design-system on its import path. Use its themed components directly to automatically give your module a polished, consistent look.

  1. In src/qml/Main.qml, add the necessary imports and replace raw Button and TextField elements with design system equivalents:

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

    ...

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

    ...

    // Instead of TextField:
    LogosTextField {
    id: inputA
    placeholderText: qsTr("a")
    }

    ...

    // Use theme colors instead of hardcoded hex values:
    Rectangle {
    color: Theme.palette.backgroundSecondary
    radius: Theme.spacing.radiusSmall
    LogosText { text: qsTr("Result"); color: Theme.palette.text }
    }
  2. Explore available components by running the design system storybook in the logos-design-system repo:

    git clone https://github.com/logos-co/logos-design-system.git
    cd 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 manual 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, …

Step 8: Configure the Nix flake

The template already wires everything up. Update the description and point calc_module at your dependency.

  1. Replace flake.nix with the following:

    {
    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";

    # Keep this placeholder as is - it gets locked to your real path in the next step.
    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;
    };
    }

    To point at a published repo instead of a local path, change the calc_module.url input to a github: URL, for example calc_module.url = "github:<your-org>/<your-calc-module>";.

Step 9: Build and run the module

Before building, confirm the calc_module shared library is present from Part 1.

  1. Confirm the shared library exists:

    # Linux
    ls ../logos-calc-module/lib/libcalc.so

    # macOS
    ls ../logos-calc-module/lib/libcalc.dylib

    If the file is missing, build it first:

    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
  2. Stage all files, then lock calc_module to your local Part 1 checkout. The --override-input flag resolves ../logos-calc-module to an absolute path and records it in flake.lock:

    git add -A
    nix flake update --override-input calc_module path:../logos-calc-module
    git add flake.lock
  3. Build and run the app. After the lock is in place, no override flag is needed on subsequent commands:

    nix run
  4. Confirm the view loads with all controls visible, then click Add with values in the input fields to test it out:

    Operation buttons visible

    Result of 3 + 5 shows 8

Step 10: Update view with live reloading (Optional)

To enable live updates to the UI, set DEV_QML_PATH to the directory that contains your view entry's basename. This tutorial sets "view": "qml/Main.qml" in metadata.json, so the directory must contain Main.qml (here: src/qml/).

  1. Run with DEV_QML_PATH set so that QML is loaded from your source tree at runtime:

    DEV_QML_PATH=$PWD/src/qml nix run .

Step 11: Add UI integration tests (Optional)

Add automated UI tests using the logos-qt-mcp test framework.

  1. 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();
  2. Stage the test file and run the hermetic CI test:

    git add tests/
    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/.

  3. To run tests interactively against an already-running app, build the test framework and run the app and tests in separate terminals:

    nix build .#test-framework -o result-mcp
    nix run . # terminal 1 — app with inspector on :3768
    node tests/ui-tests.mjs # terminal 2

Troubleshooting the C++ UI module build

The plugin fails to load at runtime with no clear error

Confirm the Q_PLUGIN_METADATA IID and Q_DECLARE_INTERFACE macro in calc_ui_cpp_interface.h both use CalcUiCppInterface_iid. A mismatch causes silent load failures.

Linker errors during nix build

Confirm ../logos-calc-module/lib/libcalc.so for Linux (or .dylib on macOS) exists and that flake.lock was updated with --override-input calc_module path:../logos-calc-module. A stale or placeholder lock file is the most common cause.

DEV_QML_PATH does not seem to take effect

Confirm the path points at the directory containing Main.qml directly — not a parent directory. The host looks for the basename from "view" in metadata.json inside the directory you provide.