25 KiB
Tutorial Part 3: Building a C++ UI Module
This is Part 3 of the Logos module tutorial series. In Part 2 you built a QML UI plugin. Now you'll build a native C++ Qt widget plugin that calls calc_module through a typed backend class.
What you'll build: A calc_ui_cpp C++ plugin with two options for the UI:
- Option A — QML loaded from C++: A
QQuickWidgetinside the plugin loading the sameMain.qmlas the QML plugin, withCalcBackendexposed as a context property — plus dev mode for editing QML without rebuilding - Option B — Pure Qt widget:
QPushButton,QLineEdit,QLabelwired directly to a backend class
Why C++ over QML-only?
| QML plugin (Part 2) | C++ UI plugin (Part 3) | |
|---|---|---|
| Compilation | No | Yes (CMake) |
| Backend calls | Via logos.callModule() IPC bridge |
Via LogosAPI* directly in C++ |
| Type safety | Weak — all args travel as QVariant |
Strong — C++ types preserved |
| Sandboxing | Yes | No |
| QML support | Native | Optional via QQuickWidget |
The C++ backend class also fixes the type coercion issue from Part 2 — int arguments stay int all the way to the module.
Prerequisites:
- Completed Part 1 — you have a working
calc_module - Nix with flakes enabled
How It Works
+----------------------+ CalcBackend::add(3, 5) +-------------------+
| calc_ui_cpp | --------------------------------> | calc_module |
| C++ Qt plugin | LogosAPI* / invokeRemoteMethod | C++ plugin |
| createWidget() | | add(int, int) |
+----------------------+ +-------------------+
^
| loaded by
v
logos-standalone-app / logos-basecamp
The plugin implements createWidget() which returns a QWidget*. The widget is shown in the host app's window. A CalcBackend class holds LogosAPI* and makes typed calls to calc_module.
Step 1: Scaffold
mkdir logos-calc-ui-cpp && cd logos-calc-ui-cpp
nix flake init -t github:logos-co/logos-module-builder#ui-module
git init && git add -A
This gives you:
logos-calc-ui-cpp/
├── flake.nix
├── module.yaml
├── metadata.json
├── CMakeLists.txt
└── src/
├── ui_example_interface.h
├── ui_example_plugin.h
└── ui_example_plugin.cpp
Rename the source files to match your module:
mv src/ui_example_interface.h src/calc_ui_cpp_interface.h
mv src/ui_example_plugin.h src/calc_ui_cpp_plugin.h
mv src/ui_example_plugin.cpp src/calc_ui_cpp_plugin.cpp
Step 2: module.yaml
name: calc_ui_cpp
version: 1.0.0
type: ui
category: tools
description: "Calculator C++ UI — widget frontend for calc_module"
dependencies:
- calc_module
nix_packages:
build: []
runtime: []
external_libraries: []
cmake:
find_packages: []
extra_sources: []
proto_files: []
Step 3: metadata.json
{
"name": "calc_ui_cpp",
"version": "1.0.0",
"description": "Calculator C++ UI — widget frontend for calc_module",
"type": "ui",
"main": "calc_ui_cpp_plugin",
"dependencies": ["calc_module"],
"category": "tools"
}
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
SOURCES
src/calc_ui_cpp_interface.h
src/calc_ui_cpp_plugin.h
src/calc_ui_cpp_plugin.cpp
src/calc_backend.h
src/calc_backend.cpp
)
find_package(Qt6 REQUIRED COMPONENTS Widgets)
target_link_libraries(calc_ui_cpp_module_plugin PRIVATE Qt6::Widgets)
For Option A (QML inside the plugin) you will add
Quick QuickWidgetsandqt_add_resources— covered in Step 7.
Step 5: Interface Header (src/calc_ui_cpp_interface.h)
#ifndef CALC_UI_CPP_INTERFACE_H
#define CALC_UI_CPP_INTERFACE_H
#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 6: Backend Class
The backend class is the key addition over the QML plugin. It holds a LogosModules* wrapper — a typed C++ SDK generated at build time from module.yaml — and exposes Q_INVOKABLE methods that call calc_module through it. Because the calls go through a generated typed class, argument types are preserved — no QString/int coercion issues.
How the generated SDK works
When module.yaml declares dependencies: [calc_module] and calc_module is passed as a flake input via moduleInputs, the build system runs logos-cpp-generator before compilation. This produces:
logos_sdk.h/logos_sdk.cpp— theLogosModulesumbrella class with one typed member per dependencycalc_module_api.h/calc_module_api.cpp— the per-module wrapper included bylogos_sdk.h
LogosModules is constructed with a LogosAPI* and provides a member named after each declared dependency (snake_case). All IPC routing happens inside the generated code — your backend just calls methods directly:
m_logos->calc_module.add(3, 5) // typed: int add(int, int) over IPC
This is the same pattern used in production modules such as logos-storage-ui.
src/calc_backend.h
#ifndef CALC_BACKEND_H
#define CALC_BACKEND_H
#include <QObject>
#include <QString>
#include "logos_sdk.h" // generated at build time from module.yaml dependencies
class LogosAPI;
class CalcBackend : public QObject
{
Q_OBJECT
public:
explicit CalcBackend(LogosAPI* api, QObject* parent = nullptr);
Q_INVOKABLE int add(int a, int b);
Q_INVOKABLE int multiply(int a, int b);
Q_INVOKABLE int factorial(int n);
Q_INVOKABLE int fibonacci(int n);
Q_INVOKABLE QString libVersion();
private:
LogosModules* m_logos; // generated umbrella wrapper
};
#endif // CALC_BACKEND_H
src/calc_backend.cpp
#include "calc_backend.h"
CalcBackend::CalcBackend(LogosAPI* api, QObject* parent)
: QObject(parent), m_logos(new LogosModules(api)) {}
int CalcBackend::add(int a, int b) { return m_logos->calc_module.add(a, b); }
int CalcBackend::multiply(int a, int b) { return m_logos->calc_module.multiply(a, b); }
int CalcBackend::factorial(int n) { return m_logos->calc_module.factorial(n); }
int CalcBackend::fibonacci(int n) { return m_logos->calc_module.fibonacci(n); }
QString CalcBackend::libVersion() { return m_logos->calc_module.libVersion(); }
LogosModules is constructed once with LogosAPI*. Each member (calc_module) is a generated proxy that routes calls to the corresponding module process over Qt Remote Objects IPC. No raw invokeRemoteMethod, no string method names, no manual QVariant unwrapping.
Step 7: Option A — QML Loaded from C++
The plugin loads src/qml/Main.qml into a QQuickWidget and exposes CalcBackend as a QML context property. The QML is identical in structure to logos-calc-ui/Main.qml (Part 2), but calls backend.* methods directly instead of routing through the logos.callModule() IPC bridge — so argument types are preserved and there is no sandboxing overhead.
7.1 Add the QML file
Create src/qml/Main.qml. The structure mirrors logos-calc-ui/Main.qml exactly; the only difference is that buttons call backend.* methods directly instead of routing through logos.callModule(...):
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
property string result: ""
property string errorText: ""
ColumnLayout {
anchors.fill: parent
anchors.margins: 24
spacing: 16
// ── Title ──────────────────────────────────────────────
Text {
text: "Logos Calculator (C++ backend)"
font.pixelSize: 20
color: "#ffffff"
Layout.alignment: Qt.AlignHCenter
}
// ── Two-operand operations ─────────────────────────────
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"
onClicked: root.result = String(backend.add(inputA.text, inputB.text))
}
Button {
text: "Multiply"
onClicked: root.result = String(backend.multiply(inputA.text, inputB.text))
}
}
// ── Single-operand operations ──────────────────────────
RowLayout {
spacing: 12
Layout.fillWidth: true
TextField { id: inputN; placeholderText: "n"; Layout.preferredWidth: 80; validator: IntValidator { bottom: 0 } }
Button { text: "Factorial"; onClicked: root.result = String(backend.factorial(inputN.text)) }
Button { text: "Fibonacci"; onClicked: root.result = String(backend.fibonacci(inputN.text)) }
Button { text: "libcalc version"; onClicked: root.result = backend.libVersion() }
}
// ── Result display ─────────────────────────────────────
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 }
}
}
7.2 Update CMakeLists.txt
Add Quick and QuickWidgets, and embed the QML as a Qt resource:
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
SOURCES
src/calc_ui_cpp_interface.h
src/calc_ui_cpp_plugin.h
src/calc_ui_cpp_plugin.cpp
src/calc_backend.h
src/calc_backend.cpp
)
find_package(Qt6 REQUIRED COMPONENTS Widgets Quick QuickWidgets)
target_link_libraries(calc_ui_cpp_module_plugin PRIVATE
Qt6::Widgets
Qt6::Quick
Qt6::QuickWidgets
)
qt_add_resources(calc_ui_cpp_module_plugin "qml_resources"
PREFIX "/"
FILES
src/qml/Main.qml
)
7.3 createWidget() — load QML
Replace calc_ui_cpp_plugin.cpp with:
#include "calc_ui_cpp_plugin.h"
#include "calc_backend.h"
#include "logos_api.h"
#include <QDebug>
#include <QDir>
#include <QQuickWidget>
#include <QQmlContext>
#include <QUrl>
CalcUiCppPlugin::CalcUiCppPlugin(QObject* parent) : QObject(parent) {}
CalcUiCppPlugin::~CalcUiCppPlugin() {}
void CalcUiCppPlugin::initLogos(LogosAPI* api)
{
m_logosAPI = api;
}
QWidget* CalcUiCppPlugin::createWidget(LogosAPI* logosAPI)
{
auto* backend = new CalcBackend(logosAPI);
auto* quickWidget = new QQuickWidget();
quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView);
quickWidget->rootContext()->setContextProperty("backend", backend);
// Dev mode: set QML_PATH to the directory containing Main.qml to load
// from the filesystem without rebuilding. Example: export QML_PATH=$PWD/src/qml
QString devSource = qgetenv("QML_PATH");
QUrl qmlUrl = devSource.isEmpty()
? QUrl("qrc:/src/qml/Main.qml")
: QUrl::fromLocalFile(QDir(devSource).filePath("Main.qml"));
quickWidget->setSource(qmlUrl);
if (quickWidget->status() == QQuickWidget::Error) {
qWarning() << "CalcUiCppPlugin: failed to load QML";
for (const auto& e : quickWidget->errors())
qWarning() << e.toString();
}
return quickWidget;
}
void CalcUiCppPlugin::destroyWidget(QWidget* widget)
{
delete widget;
}
7.4 Dev Mode
When QML_PATH is set, the plugin loads Main.qml from disk instead of the embedded resource. You can edit QML layout, styling, and property bindings without a Nix rebuild — just restart the app to pick up changes.
# Run with dev mode enabled
QML_PATH=$PWD/src/qml \
nix run . --override-input calc_module path:../logos-calc-module -- \
--modules-dir ./modules
What still requires a rebuild:
- Changes to
.cpp/.hfiles (backend logic, plugin interface)- Changes to
CMakeLists.txtormodule.yamlWhat does not require a rebuild:
- Any
.qmlchange — layout, styling, property bindings, JS logic
Step 8: Option B — Pure Qt Widget
The plugin creates a standard Qt widget using layouts and connects button clicks to the backend. No QML, no additional Qt modules — just Qt6::Widgets.
src/calc_ui_cpp_plugin.h
#ifndef CALC_UI_CPP_PLUGIN_H
#define CALC_UI_CPP_PLUGIN_H
#include <QObject>
#include <QWidget>
#include <QVariantList>
#include "calc_ui_cpp_interface.h"
class LogosAPI;
class CalcUiCppPlugin : public QObject, public CalcUiCppInterface
{
Q_OBJECT
Q_PLUGIN_METADATA(IID CalcUiCppInterface_iid FILE "metadata.json")
Q_INTERFACES(CalcUiCppInterface PluginInterface)
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);
Q_INVOKABLE QWidget* createWidget(LogosAPI* logosAPI = nullptr);
Q_INVOKABLE void destroyWidget(QWidget* widget);
signals:
void eventResponse(const QString& eventName, const QVariantList& args);
private:
LogosAPI* m_logosAPI = nullptr;
};
#endif // CALC_UI_CPP_PLUGIN_H
src/calc_ui_cpp_plugin.cpp
#include "calc_ui_cpp_plugin.h"
#include "calc_backend.h"
#include "logos_api.h"
#include <QDebug>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
CalcUiCppPlugin::CalcUiCppPlugin(QObject* parent) : QObject(parent) {}
CalcUiCppPlugin::~CalcUiCppPlugin() {}
void CalcUiCppPlugin::initLogos(LogosAPI* api)
{
m_logosAPI = api;
}
QWidget* CalcUiCppPlugin::createWidget(LogosAPI* logosAPI)
{
auto* backend = new CalcBackend(logosAPI);
auto* widget = new QWidget();
auto* layout = new QVBoxLayout(widget);
layout->setContentsMargins(24, 24, 24, 24);
layout->setSpacing(16);
// ── Title ──────────────────────────────────────────────────
auto* title = new QLabel("Logos Calculator (C++)");
title->setAlignment(Qt::AlignHCenter);
layout->addWidget(title);
// ── Two-operand row ────────────────────────────────────────
auto* twoOpRow = new QHBoxLayout();
auto* inputA = new QLineEdit(); inputA->setPlaceholderText("a"); inputA->setMaximumWidth(80);
auto* inputB = new QLineEdit(); inputB->setPlaceholderText("b"); inputB->setMaximumWidth(80);
auto* addBtn = new QPushButton("Add");
auto* mulBtn = new QPushButton("Multiply");
twoOpRow->addWidget(inputA);
twoOpRow->addWidget(inputB);
twoOpRow->addWidget(addBtn);
twoOpRow->addWidget(mulBtn);
twoOpRow->addStretch();
layout->addLayout(twoOpRow);
// ── Single-operand row ─────────────────────────────────────
auto* oneOpRow = new QHBoxLayout();
auto* inputN = new QLineEdit(); inputN->setPlaceholderText("n"); inputN->setMaximumWidth(80);
auto* facBtn = new QPushButton("Factorial");
auto* fibBtn = new QPushButton("Fibonacci");
auto* verBtn = new QPushButton("libcalc version");
oneOpRow->addWidget(inputN);
oneOpRow->addWidget(facBtn);
oneOpRow->addWidget(fibBtn);
oneOpRow->addWidget(verBtn);
oneOpRow->addStretch();
layout->addLayout(oneOpRow);
// ── Result display ─────────────────────────────────────────
auto* resultLabel = new QLabel("Enter values and press a button");
resultLabel->setAlignment(Qt::AlignHCenter);
layout->addWidget(resultLabel);
layout->addStretch();
// ── Wire up buttons ────────────────────────────────────────
auto show = [resultLabel](const QString& v) { resultLabel->setText(v); };
QObject::connect(addBtn, &QPushButton::clicked, [=] {
show(QString::number(backend->add(inputA->text().toInt(), inputB->text().toInt())));
});
QObject::connect(mulBtn, &QPushButton::clicked, [=] {
show(QString::number(backend->multiply(inputA->text().toInt(), inputB->text().toInt())));
});
QObject::connect(facBtn, &QPushButton::clicked, [=] {
show(QString::number(backend->factorial(inputN->text().toInt())));
});
QObject::connect(fibBtn, &QPushButton::clicked, [=] {
show(QString::number(backend->fibonacci(inputN->text().toInt())));
});
QObject::connect(verBtn, &QPushButton::clicked, [=] {
show(backend->libVersion());
});
return widget;
}
void CalcUiCppPlugin::destroyWidget(QWidget* widget)
{
delete widget;
}
Step 9: flake.nix
Pass standaloneApp to mkLogosModule and you get apps.default (i.e. nix run) for free — no manual apps block required.
Important — moduleInputs: Because module.yaml declares dependencies: [calc_module], the build system runs logos-cpp-generator before compiling your C++ sources. The generator introspects calc_module's built plugin to produce logos_sdk.h / logos_sdk.cpp (and per-module calc_module_api.h / calc_module_api.cpp). These are the files your backend includes as #include "logos_sdk.h". For this to work, calc_module must be available as a built Nix package at code-generation time — that is what moduleInputs provides. Without it, the build fails with 'logos_sdk.h' file not found.
{
description = "Calculator C++ UI plugin for Logos - widget frontend for calc_module";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
logos-standalone-app.url = "github:logos-co/logos-standalone-app";
calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module";
};
outputs = { logos-module-builder, logos-standalone-app, calc_module, ... }:
logos-module-builder.lib.mkLogosModule {
src = ./.;
configFile = ./module.yaml;
moduleInputs = { inherit calc_module; };
logosStandalone = logos-standalone-app;
iconFiles = [ ./icons/calc.png ];
};
}
standaloneApp tells mkLogosModule to wire up apps.default automatically. It stages the compiled plugin alongside metadata.json and any icon files into a Nix store directory, then produces a shell script that calls logos-standalone-app with that directory — exactly what nix run executes.
Step 10: Build and Test
10.1 Build
git add -A
nix build --override-input calc_module path:../logos-calc-module
Inspect the output:
lm ./result/lib/calc_ui_cpp_plugin.dylib
You should see createWidget and destroyWidget in the methods list.
10.2 UI only (layout preview)
nix run . --override-input calc_module path:../logos-calc-module
The widget opens. No backend connected yet, so button clicks will silently return 0 (CalcBackend logs a warning when calc_module is not connected).
Why
--override-input?calc_module.urlinflake.nixpoints to the published GitHub URL. For local development,--override-inputredirects it to the local sibling directory. This is the same mechanismws build --local/ws build --auto-localuses throughout the workspace.
10.3 Full functionality (with modules)
nix build 'github:logos-co/logos-package-manager-module#cli' --out-link ./pm
mkdir -p modules
# Install capability_module
nix bundle --bundler 'github:logos-co/nix-bundle-lgx' \
'github:logos-co/logos-capability-module' -o lgx-capability
./pm/bin/lgpm --modules-dir ./modules install --file lgx-capability/*.lgx
# Bundle and install calc_module (from Part 1)
cd ../logos-calc-module
nix bundle --bundler 'github:logos-co/nix-bundle-lgx' '.#lib' -o lgx-result
cd ../logos-calc-ui-cpp
./pm/bin/lgpm --modules-dir ./modules install --file ../logos-calc-module/lgx-result/*.lgx
nix run . --override-input calc_module path:../logos-calc-module -- --modules-dir ./modules
Step 11: Load in logos-basecamp
11.1 Create LGX packages
# Package calc_module (from Part 1)
cd ../logos-calc-module
nix bundle --bundler 'github:logos-co/nix-bundle-lgx#portable' '.#lib' -o lgx-calc-module
cd ../logos-calc-ui-cpp
# Package the C++ UI plugin
nix bundle --bundler 'github:logos-co/nix-bundle-lgx#portable' '.' -o lgx-calc-ui-cpp
11.2 Install via logos-basecamp UI
- Open
logos-basecamp - Go to Package Manager
- Click Install from file
- Select
lgx-calc-module/*.lgx— installscalc_module - Repeat for
lgx-calc-ui-cpp/*.lgx— installscalc_ui_cpp
The "Calculator" tab appears in the sidebar.
11.3 Install via CLI (alternative)
nix build 'github:logos-co/logos-package-manager-module#cli' --out-link ./pm
./pm/bin/lgpm install --file lgx-calc-module/*.lgx
./pm/bin/lgpm install --file lgx-calc-ui-cpp/*.lgx
11.4 Build logos-basecamp from source
Build a local logos-basecamp binary, then use lgpm to populate a modules directory and run it:
# Build logos-basecamp
nix build 'github:logos-co/logos-basecamp' -o basecamp-result
# Create module directories
mkdir -p modules ui-plugins
# Build lgpm CLI
nix build 'github:logos-co/logos-package-manager-module#cli' --out-link ./pm
# Install capability_module (required by all UI plugins)
nix bundle --bundler 'github:logos-co/nix-bundle-lgx' \
'github:logos-co/logos-capability-module' -o lgx-capability
./pm/bin/lgpm --modules-dir ./modules install --file lgx-capability/*.lgx
# Bundle and install calc_module (local, not portable)
cd ../logos-calc-module
nix bundle --bundler 'github:logos-co/nix-bundle-lgx' '.#lib' -o lgx-calc-module-local
cd ../logos-calc-ui-cpp
./pm/bin/lgpm --modules-dir ./modules install --file ../logos-calc-module/lgx-calc-module-local/*.lgx
# Bundle and install the C++ UI plugin
nix bundle --bundler 'github:logos-co/nix-bundle-lgx' '.' -o lgx-calc-ui-cpp-local
./pm/bin/lgpm --modules-dir ./ui-plugins install --file lgx-calc-ui-cpp-local/*.lgx
# Run basecamp with the populated directories
./basecamp-result/bin/logos-basecamp \
--modules-dir ./modules \
--ui-plugins-dir ./ui-plugins
Local vs portable: A locally-built
logos-basecamp(vianix build) expects local.lgxpackages (built without#portable). Portable builds (AppImage, macOS app bundle) expect portable.lgxpackages.
Recap: Three Module Types
| Core (Part 1) | QML UI (Part 2) | C++ UI (Part 3) | |
|---|---|---|---|
| Language | C++ | QML / JS | C++ (+ optional QML) |
| Compilation | Yes | No | Yes |
| Backend calls | Exposed via Q_INVOKABLE |
logos.callModule() IPC |
LogosAPI* → invokeRemoteMethod() |
| Type safety | Strong | Weak (QVariant/QString) | Strong |
| Sandboxed | No | Yes | No |
| QML support | — | Native | Via QQuickWidget |
| Template | #default |
#ui-qml-module |
#ui-module |
What's Next
- Generated type-safe wrappers — instead of raw
invokeRemoteMethod, uselogos-cpp-generatorto generate a typedCalcModuleClientclass. See Developer Guide Section 6.2 - Events — core modules emit
eventResponsesignals; connect to them from your backend class viaLogosAPIClient - Use the Logos Design System in Option B QML —
import Logos.Themeandimport Logos.Controlsare available when running insidelogos-basecamp