Files
ift-docs/docs/core/build-modules/build-a-logos-cpp-ui-module.md

22 KiB

title, doc_type, product, topics, steps_layout, authors, owner, doc_version, slug, sidebar_position
title doc_type product topics steps_layout authors owner doc_version slug sidebar_position
Build a Logos C++ UI module procedure core core sectioned iurimatias, kashepavadan logos 1 build-a-logos-cpp-ui-module 4

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.