name:"Tutorial Part 3: Building a C++ UI Module (Process-Isolated)"
output:tutorial-cpp-ui-app.md
project_name:logos-calc-ui-cpp
requires:
- tutorial-qml-ui-app.test.yaml
release:""
intro:|
This is Part 3 of the Logos module tutorial series. In [Part 2](tutorial-qml-ui-app.md) 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.
- A `.rep` file exercising the **full QtRO surface**, not just slots: value slots and a void slot, `PROP`s of different types (`QString`, `int`) and both modes (`READONLY` and `READWRITE`), and a `SIGNAL` — the one Qt-typed contract you author
- A C++ `*Backend` class that derives the generated `SimpleSource` (implements the `.rep`) and `LogosUiPluginContext` (gives `modules()` Qt-typed callers, event subscriptions, and `onContextReady()`)
- A QML view that drives each surface: `logos.watch()` for slot replies, plain property reads for auto-synced PROPs, a property *write* for the READWRITE memory register, a `Connections` block for the signal, and a label fed by a typed `calc_module` **event subscription**
- "Completed [Part 1](tutorial-wrapping-c-library.md) — you have a working `calc_module` with the shared library built (`.so` on Linux, `.dylib` on macOS in `logos-calc-module/lib/`)"
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 `LogosUiPluginContext`. A UI plugin is a view, not a module, so the context carries only the dependency surface: `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). The dep wrappers are **Qt-typed** (`QString`, `int`, ...) to match the `.rep` slots — no std<->Qt conversions in the view.
The `.rep` is more than a list of slots, and this tutorial exercises the whole of it: value slots and a void slot, `PROP`s of different types and both modes (a slot-driven `READONLY` counter, a `READWRITE` memory register QML writes back, an event-fed `READONLY` string), and a `SIGNAL` the backend pushes to the view. The calculator slots *call* `calc_module`; `record()` feeds the PROP + SIGNAL after each call; and `onContextReady()` *subscribes* to `calc_module`'s `versionReady` event to feed the auto-syncing event PROP.
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.
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.)
Create the icon directory and add a placeholder icon — a PNG that is exactly 256×256, the only size LGX packaging accepts (displayed in the `logos-basecamp` sidebar when the module is loaded):
- `"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`.)
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`, `int`) because that's the Qt Remote Objects wire contract. `repc` generates:
- `rep_calc_ui_cpp_source.h` — `CalcUiCppSimpleSource` with virtual slots your `*Backend` overrides, a `set<Prop>(...)` setter + change signal for each PROP (`setVersionEvent`, `setComputeCount`, `setMemory`), and the `computed(...)` signal you `emit`
- `rep_calc_ui_cpp_replica.h` — `CalcUiCppReplica` with typed methods, the auto-synced `versionEvent` / `computeCount` / `memory` properties, and the `computed` signal the QML view reads, writes, and connects to
One contract, four kinds of surface — slots are only the first:
- **SLOT** return values arrive as `QRemoteObjectPendingReply` — `logos.watch()` turns them into JS Promises in QML. `add`/`multiply`/… return values; `announceVersion()` is a **void** slot (fire-and-forget, no reply to watch).
- **PROP** auto-syncs from the backend to every QML replica with no polling. We use three, of two types and two modes: `versionEvent` (QString, READONLY, fed by a module-**event subscription** in Step 5), `computeCount` (int, READONLY, bumped by each slot), and `memory` (int, **READWRITE** — QML assigns `backend.memory = …` and the new value round-trips through the backend source and back to the view).
- **SIGNAL** is a backend → view push that is neither a return value nor a synced property: `computed(op, result)` fires after each calculation and the QML view catches it with a `Connections` block.
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:
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.
- **`LogosUiPluginContext`** — gives `modules()` (Qt-typed callers + event subscriptions for your `dependencies`) and `onContextReady()`. A UI plugin is a view, not a module, so that is all the context carries.
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.
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 (plus any private helpers of your own, like `record`).
Beyond the call-and-return slots, the backend drives the rest of the `.rep` surface:
- **PROPs** — each gets a generated `set<Prop>()` setter on the `SimpleSource`. The backend calls `setComputeCount(...)` (and could call `setMemory(...)`); `memory` is READWRITE, so QML can write it too.
- **SIGNAL** — `computed(...)` is declared on the `SimpleSource`, so the backend just `emit`s it.
- **Event subscriptions** — `LogosUiPluginContext` gives typed `modules().dep.on<Event>(...)` and the `onContextReady()` hook. `onContextReady()` subscribes to `calc_module`'s `versionReady` event and pipes the payload into the `versionEvent` PROP, which Qt Remote Objects auto-syncs to the view. Arm subscriptions in `onContextReady()` (not the constructor) so they're live the moment the backend is wired.
- Each value slot delegates straight to `calc_module` via `modules().calc_module.<method>(...)` — the generated typed SDK, type-safe with no `QVariant` — then calls `record()` to drive the PROP + SIGNAL surfaces before returning. Slot return values travel back to the QML replica via Qt Remote Objects.
- `modules().calc_module.libVersion()` returns `QString` even though Part 1's `calc_module` declares it `std::string`. A UI plugin is Qt-typed (api-style `qt`), so the generated `modules().<dep>` wrapper exposes Qt types (`QString`, `int`, ...) that match the `.rep` slots directly — the std<->Qt conversion happens inside the generated wrapper, not in your view code.
- **PROP vs. SIGNAL vs. return value — three ways to get data to the view.** `record()` shows two of them side by side: `setComputeCount(...)` writes a **PROP** that Qt Remote Objects auto-syncs (the view reads `backend.computeCount` with no polling), while `emit computed(...)` fires a **SIGNAL** the view catches in a `Connections` block. Both are independent of the slot's `logos.watch()` return value. The READWRITE `memory` PROP is the fourth path — there the *view* writes and the value syncs back.
- **Event subscription vs. method call.** `announceVersion()` makes `calc_module` *emit*; `onContextReady()` *subscribes* to that emission. The callback is Qt-typed (`const QString&`) and `setVersionEvent(...)` is the generated PROP setter — the value reaches QML with no polling and no return value to await. Arm the subscription in `onContextReady()`, never the constructor: `modules()` isn't wired until the framework calls it.
- No `initLogos`, no manual `LogosModules` construction — `modules()` is wired by the generated plugin before any slot runs or any event arrives.
- **SLOT return value:** `logos.watch(backend.add(1, 2), ...)` — the reply as a JS Promise.
- **READONLY PROP (slot-driven):** `backend.computeCount` is read directly in a binding. The backend bumps it via `setComputeCount(...)` after each calculation, and Qt Remote Objects auto-syncs it — the label re-evaluates with no polling.
- **READWRITE PROP:** `backend.memory` is both read (the "Memory:" label) and **written** (`backend.memory = ...` in the Store/Clear buttons). Assigning the property on the replica pushes the value to the backend source; it syncs back to every replica, so the label updates once the write lands.
- **SIGNAL:** `Connections { target: root.backend; function onComputed(op, result) { ... } }` catches the backend's `computed` push. No return value, no property — a one-shot event the view reacts to.
- **Event-fed PROP:** `backend.versionEvent` is read directly — no `logos.watch()`, no polling. The "Announce version" button calls the void `announceVersion()` slot (which makes `calc_module` emit `versionReady`); the backend's subscription catches the event and writes the PROP, and Qt Remote Objects pushes the new value straight into this label. This is the event path, distinct from the call-and-return slots above.
- **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 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.
**Discover what's available** by running the storybook:
```bash
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):
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.
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](tutorial-wrapping-c-library.md#15-build-the-shared-library).
`mkLogosQmlModule` handles everything: compiles the C++ backend (because `main` is set), bundles the QML view, generates LGX packages, and wires up `nix run`.
First, make sure your local `calc_module` is built and its shared library is present in `lib/` (see [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library)):
steps:
- title:"Ensure `calc_module` is built"
run:"ls ../logos-calc-module/lib/libcalc.{ext}"
code_block:|
ls ../logos-calc-module/lib/libcalc.so # Linux
ls ../logos-calc-module/lib/libcalc.dylib # macOS
post_text:|
If the file is missing, build it first (as covered in [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library)):
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`:
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()`.
Every surface the `.rep` declares is now proven end to end from one click:
- **SLOT return value** — the result `8` comes from `calc_module.add(3, 5)`, the call-and-return path (QML replica → Qt Remote Objects → ui-host backend → typed SDK → `calc_module`), delivered via `logos.watch()`.
- **READONLY PROP (slot-driven)** — `Computations: 1` is the `computeCount` PROP: the same `add` call ran `setComputeCount(...)` in the backend's `record()`, and Qt Remote Objects synced it to the view with no polling.
- **SIGNAL** — `Last op (signal): add = 8` is the `computed` signal the backend `emit`ted; the QML `Connections` block caught it. It carried the op name *and* the result, neither of which is a property or a return value.
- **READWRITE PROP** — `Memory: 0` → `Memory: 8` proves the *write* direction: the Store button assigned `backend.memory = 8` in QML, which pushed to the backend source and synced back to the label. Properties aren't read-only mirrors; QML can drive them too.
- **Event-fed PROP** — `Version event: 1.0.0` is the event path: clicking *Announce version* called `calc_module.libVersionNotify()`, which emitted `versionReady("1.0.0")`; the backend's `modules().calc_module.onVersionReady(...)` subscription — armed in `onContextReady()` — caught it and wrote the `versionEvent` PROP, which Qt Remote Objects synced into the view. The label was `(none yet)` until the event fired, so seeing the version proves the typed subscription delivered.
Run from the repo root and the launcher finds your QML source automatically, then watches it. Edit a `.qml` file, save, and the view re-renders in about 200 ms. It reports what it picked up on startup:
`ui-dev` is the same wrapper `nix run .` uses — dependency modules bundled and loaded identically — exposed as a package so it lands in `./result/bin`. It is a development target and is never bundled into `.lgx` packages.
- **Any `.qml`/`.js` under your view directory**, including files and folders created after launching.
- **The backend keeps running.** A module's C++ backend lives in a separate `ui-host` process, so its state and connections survive a reload.
- **QML-side state resets** — scroll position, text fields, current tab.
- **A syntax error is recoverable.** It's logged with a line number and the view blanks; the next save that compiles restores it.
- **C++, `.rep`, `metadata.json` and CMake changes still need a rebuild.** Re-run `nix build .#ui-dev` and relaunch.
**Why not `nix run .`?** It re-evaluates the flake and rehashes the source tree on every invocation. Since `src = ./.` covers every tracked file including `*.qml`, even a one-character QML edit rebuilds the plugin before the app starts. Building `ui-dev` once avoids that entirely.
> **Custom layouts:** the launcher looks for the `view` entry from `metadata.json` under `src/<viewDir>/`, then `<viewDir>/`. If your tree differs, set `DEV_QML_PATH` to the directory holding the entry file and it takes precedence.
> This does not work with `logos-basecamp`. Basecamp loads QML plugins from its own data directory, so source edits are not reflected until you rebuild and reinstall the `.lgx` package.
Add automated UI tests using the [logos-qt-mcp](https://github.com/logos-co/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.
steps:
- title:"Create a test file"
text:|
Create `tests/ui-tests.mjs`:
file:
path:tests/ui-tests.mjs
language:javascript
content:|
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) => {
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):
You declare each pattern in the `.rep` and implement it in your `*Backend` (which derives the generated `SimpleSource` + `LogosUiPluginContext`). There is no hand-written plugin — the `*Plugin`/`*Interface` glue is generated. Every row but **Model** is live in this tutorial's `.rep` (✓), and the UI test in Step 9 drives each one:
The last live row uses the `LogosUiPluginContext` surface — Qt-typed `modules()` callers and event subscriptions armed in `onContextReady()`: `versionEvent` is a PROP fed by the `modules().calc_module.onVersionReady(...)` subscription, and the *Announce version* button drives it. **Model** is the one pattern shown but not built here — for a `QAbstractItemModel*` Q_PROPERTY remoted via `logos.model()`, see [Next Steps](#next-steps).
- This tutorial already exercises slots, PROPs of different types and modes, and a signal — extend them with more state for your own UI
- Add an **enum** to the `.rep` (`ENUM Status { Idle, Busy }` — no parentheses, unlike `SLOT`/`PROP`/`SIGNAL`) — the factory registers it under the `Logos.<ModuleName>` QML URI, so `import Logos.CalcUiCpp` then `CalcUiCpp.Busy` works in bindings
- Use `logos.model()` for list views backed by a `QAbstractItemModel*` Q_PROPERTY — the **Model** row above, the one `.rep` pattern this tutorial doesn't build
- **Use the Logos Design System** in your QML — see [the design system step](#use-the-logos-design-system-in-your-qml). Browse components in the storybook (`cd repos/logos-design-system && nix run`); file issues at `logos-co/logos-design-system`.