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. what_you_build: | A `calc_ui_cpp` module with: - A `.rep` file defining the remote interface (slots) — the one Qt-typed contract you author - A C++ `*Backend` class that derives the generated `SimpleSource` (implements the `.rep`) and `LogosModuleContext` (gives `modules()` typed callers, event subscriptions, and `onContextReady()`) - 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 You write only the `.rep` and the `Backend`. The `*Plugin`/`*Interface` classes, the `initLogos`/`setBackend` wiring, and the typed SDK are generated. comparison: | **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 | `modules()` typed SDK in C++ (type-safe, no QVariant) | | Type safety | Args travel as `QVariant` | C++ types preserved | | QML ↔ backend | Direct bridge | Qt Remote Objects (typed replica) | | `.rep` file | Not needed | Required — your view contract, the one file you author | | C++ you write | None | One `*Backend` class — no hand-written plugin/interface | prerequisites: - "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/`)" - Nix with flakes enabled sections: # ── Architecture (prose only) ─────────────────────────────────────────────── - title: "Architecture" text: | ``` 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) ┌──────────┼──────────────────────────────────┐ │ ▼ │ │ CalcUiCppBackend (you write this) │ │ : CalcUiCppSimpleSource (impl .rep) │ │ : LogosModuleContext (modules()) │ │ int add(int a, int b) override { │ │ return modules().calc_module.add(a,b);│ │ } │ │ │ │ │ │ modules() typed SDK │ │ ▼ │ │ calc_module (loaded in ui-host) │ └─────────────────────────────────────────────┘ ``` You author two files: the `.rep` (your view contract) and the `*Backend` class. Everything else is generated. The `.rep` file declares the interface. At build time, Qt's `repc` compiler generates: - **`CalcUiCppSimpleSource`** — base class the backend implements - **`CalcUiCppReplica`** — typed replica the QML view uses - **`calc_ui_cpp_replica_factory`** — separate plugin that the host loads to create typed replicas 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 `LogosModuleContext`, which gives it the same surface a universal **core** module impl gets: `modules()` typed method callers for your `dependencies`, typed **event subscriptions** (`modules().dep.on(...)`), and `onContextReady()` (fires when the backend is wired, so subscriptions are live before the view's first call). `calc_module` here is call-only, but for an event-driven PROP fed from a typed subscription see the worked [ui-typed-backend doc-test](https://github.com/logos-co/logos-module-builder/blob/main/doctests/ui-typed-backend.test.yaml). # ── Step 1: Scaffold ──────────────────────────────────────────────────────── - title: "Scaffold" step: true text: | Create a new directory and initialise it from the C++ backend UI template: `mkdir logos-calc-ui-cpp && cd logos-calc-ui-cpp` steps: - run: "nix flake init -t github:logos-co/logos-module-builder{release}#ui-qml-backend" code_block: | nix flake init -t github:logos-co/logos-module-builder{release}#ui-qml-backend post_text: | 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. - run: "rm -f src/ui_example.rep src/ui_example_backend.h src/ui_example_backend.cpp" post_text: | 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.) - run: "git init && git add -A" # ── Step 2: metadata.json ─────────────────────────────────────────────────── - title: "`metadata.json`" step: true text: | Replace the template contents with your plugin's details: steps: - file: path: metadata.json language: json content: | { "name": "calc_ui_cpp", "version": "1.0.0", "type": "ui_qml", "interface": "universal", "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"], "codegen": { "rep": "src/calc_ui_cpp.rep" }, "nix": { "packages": { "build": [], "runtime": [] }, "external_libraries": [], "cmake": { "find_packages": [], "extra_sources": [], "extra_include_dirs": [], "extra_link_libraries": [] } } } post_text: | Create the icon directory and add a placeholder icon (displayed in the `logos-basecamp` sidebar when the module is loaded): - run: "mkdir -p icons && echo 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAmElEQVR4nO3QMREAIBDAsFeEN3ziCWRkoEP2XmedfX82OkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAO0BN/SiO/PatoIAAAAASUVORK5CYII=' | base64 -d > icons/calc.png" code_block: | mkdir -p icons # Copy any PNG here — or generate a 64×64 placeholder: echo "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAmElEQVR4nO3QMREAIBDAsFeEN3ziCWRkoEP2XmedfX82OkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAO0BN/SiO/PatoIAAAAASUVORK5CYII=" | base64 -d > icons/calc.png post_text: | Key fields: - `"type": "ui_qml"` — tells the builder this is a QML view module - `"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`.) - `"main": "calc_ui_cpp_plugin"` — the generated backend Qt plugin library (without extension) - `"view": "qml/Main.qml"` — the QML entry point - `"dependencies": ["calc_module"]` — core modules the backend calls via `modules()` # ── Step 3: The .rep File ───────────────────────────────────────────────────── - title: "The `.rep` File" step: true text: | Create `src/calc_ui_cpp.rep`: steps: - file: path: src/calc_ui_cpp.rep language: rep content: | 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()) } post_text: | 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`) because that's the Qt Remote Objects wire contract. `repc` generates: - `rep_calc_ui_cpp_source.h` — `CalcUiCppSimpleSource` with virtual slots your `*Backend` overrides - `rep_calc_ui_cpp_replica.h` — `CalcUiCppReplica` with typed methods the QML view calls **SLOT** return values are delivered as `QRemoteObjectPendingReply` — use `logos.watch()` in QML to get them as JS Promises. You can also declare **PROP** entries (e.g. `PROP(QString status READWRITE)`) which auto-sync from the backend to the QML replica — see the [ui-typed-backend doc-test](https://github.com/logos-co/logos-module-builder/blob/main/doctests/ui-typed-backend.test.yaml) for a PROP fed from a typed module-event subscription. # ── Step 4: CMakeLists.txt ──────────────────────────────────────────────────── - title: "`CMakeLists.txt`" step: true steps: - file: path: CMakeLists.txt language: cmake content: | 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() # Derive the module name from metadata.json — single source of truth. file(READ "${CMAKE_CURRENT_SOURCE_DIR}/metadata.json" METADATA_JSON) string(JSON MODULE_NAME GET ${METADATA_JSON} name) logos_module( NAME ${MODULE_NAME} REP_FILE src/calc_ui_cpp.rep SOURCES src/calc_ui_cpp_backend.h src/calc_ui_cpp_backend.cpp INCLUDE_DIRS src ) post_text: | 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: 1. Run `repc` to generate the source/replica headers 2. Generate the `*Plugin` / `*Interface` wrapper around your `*Backend` (because `metadata.json` sets `"interface": "universal"`) 3. Build a separate `calc_ui_cpp_replica_factory` shared library # ── Step 5: C++ Backend ─────────────────────────────────────────────────────── - title: "C++ Backend" step: true text: | 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. - **`LogosModuleContext`** — gives `modules()` (typed callers + event subscriptions for your `dependencies`) and `onContextReady()`, exactly like a universal core module impl. 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. steps: - title: "`src/calc_ui_cpp_backend.h`" file: path: src/calc_ui_cpp_backend.h language: cpp content: | #pragma once #include "rep_calc_ui_cpp_source.h" #include "logos_module_context.h" // The whole hand-written backend. Derives: // - CalcUiCppSimpleSource — generated from calc_ui_cpp.rep; override its // slots (the QML replica gets each return value via Qt Remote Objects). // - LogosModuleContext — supplies modules() (typed callers + typed event // subscriptions for "dependencies") and onContextReady(). // The *Plugin / *Interface classes (Q_PLUGIN_METADATA, initLogos wiring, // QtRO registration) are generated around it. class CalcUiCppBackend : public CalcUiCppSimpleSource, public LogosModuleContext { public: // Slots from calc_ui_cpp.rep — each delegates to calc_module. 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; }; post_text: | 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. `LogosModuleContext` also gives this backend typed **event subscriptions** (`modules().dep.on(...)`) and `onContextReady()` — arm subscriptions there so they're live before the view's first call. `calc_module` is call-only here, but the [ui-typed-backend doc-test](https://github.com/logos-co/logos-module-builder/blob/main/doctests/ui-typed-backend.test.yaml) shows a `.rep` PROP fed from a typed module-event subscription registered in `onContextReady()`. - title: "`src/calc_ui_cpp_backend.cpp`" file: path: src/calc_ui_cpp_backend.cpp language: cpp content: | #include "calc_ui_cpp_backend.h" // Generated umbrella: LogosModules (behind modules()) from // metadata.json#dependencies — typed wrappers + typed event accessors. #include "logos_sdk.h" int CalcUiCppBackend::add(int a, int b) { return modules().calc_module.add(a, b); } int CalcUiCppBackend::multiply(int a, int b) { return modules().calc_module.multiply(a, b); } int CalcUiCppBackend::factorial(int n) { return modules().calc_module.factorial(n); } int CalcUiCppBackend::fibonacci(int n) { return modules().calc_module.fibonacci(n); } QString CalcUiCppBackend::libVersion() { // calc_module is itself a universal (std-typed) module, so its typed // wrapper returns std::string. The .rep slot is QString, so convert. return QString::fromStdString(modules().calc_module.libVersion()); } post_text: | Key points: - Each slot delegates straight to `calc_module` via `modules().calc_module.(...)` — the generated typed SDK, type-safe with no `QVariant`. - Slots return values directly; they travel back to the QML replica via Qt Remote Objects. - `modules().calc_module.libVersion()` returns `std::string` because Part 1's `calc_module` is also a universal (std-typed) module — its `libVersion()` is declared `std::string`. The `.rep` slot is `QString libVersion()` (the QtRO wire type), so wrap with `QString::fromStdString(...)`. The `int` methods need no conversion: `int` ↔ `int64_t` on the wire. - No `initLogos`, no manual `LogosModules` construction — `modules()` is wired by the generated plugin before any slot runs. # ── Step 6: QML View ────────────────────────────────────────────────────────── - title: "QML View" step: true text: | Create `src/qml/Main.qml`: steps: - file: path: src/qml/Main.qml language: qml content: | 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. // No QtRemoteObjects import needed — the bridge handles it. 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 } } } post_text: | Key patterns: - `logos.module("calc_ui_cpp")` — gets the typed replica (auto-synced properties) - `logos.watch(backend.add(1, 2), ...)` — SLOT return value as JS Promise - **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 `logos` object is injected by the host at runtime — no `QtRemoteObjects` import needed # ── Step 7: Use the Logos Design System (prose only) ────────────────────────── - title: "Use the Logos Design System in your QML" step: true text: | 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. ```qml 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: ```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): - `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 8: flake.nix ───────────────────────────────────────────────────────── - title: "`flake.nix`" step: true text: | The template already wires everything up. Update the description and point `calc_module` at your dependency: steps: - file: path: flake.nix language: nix content: | { 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{release}"; # Points at your local calc_module checkout. This is a placeholder — # you lock it to your actual path in the next step with # `nix flake update --override-input` (see "Lock and build" below). 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; }; } post_text: | The `calc_module` input attribute name must match the dependency name in `metadata.json`. 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`. # ── Step 9: Build and Run ───────────────────────────────────────────────────── - title: "Build and Run" step: true text: | 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)): extra_run: run: "cd ../logos-calc-module/lib && gcc {shared_flags} -o libcalc.{ext} libcalc.c && cd ../../logos-calc-ui-cpp" code_block: | 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 - title: "Lock and build" text: | 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`: run: "git add -A" - run: "nix flake update --override-input calc_module path:../logos-calc-module" code_block: | nix flake update --override-input calc_module path:../logos-calc-module - run: "git add flake.lock" post_text: | Now that the lock pins the real path, plain `nix run` works — no override needed on subsequent commands: ```bash nix run ``` - title: "Launch and verify the UI" text: | 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()`. ui_test: launch: "nix run ." setup: - "nix build 'github:logos-co/logos-qt-mcp{release}' -o result-mcp" qt_mcp: "result-mcp" tests: - name: "App window opens with title" action: wait_for texts: ["Logos Calculator (C++ backend)"] timeout: 30000 - name: "Operation buttons visible" action: wait_for texts: ["Add", "Multiply", "Factorial", "Fibonacci"] timeout: 5000 screenshot: "calc-cpp-buttons.png" - name: "Enter operands" action: set_text find_by: "placeholderText" find_value: "a" value: "3" - name: "Set second operand" action: set_text find_by: "placeholderText" find_value: "b" value: "5" - name: "Click Add" action: click target: "Add" - name: "Result of 3 + 5 shows 8" action: wait_for texts: ["8"] timeout: 10000 screenshot: "calc-cpp-result.png" post_text: | The result `8` comes from `calc_module.add(3, 5)` executed in the C++ backend — proof the full path (QML replica → Qt Remote Objects → ui-host backend → typed SDK → `calc_module`) works end to end. # ── Step 10: Live reloading (prose only) ────────────────────────────────────── - title: "Live reloading QML with `DEV_QML_PATH`" step: true text: | 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: `src/qml/`): ```bash DEV_QML_PATH=$PWD/src/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: ```bash # 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/src/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 11: How the Pieces Connect (prose only) ────────────────────────────── - title: "How the Pieces Connect" step: true text: | 1. `nix build` → generates the `*Plugin`/`*Interface` glue around your `CalcUiCppBackend`, 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` - The generated plugin calls `initLogos()` → wires `modules()` and `onContextReady()` → `setBackend()` → `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 → your backend's `add()` runs `modules().calc_module.add(1, 2)` → returns result # ── Step 12: UI Integration Tests ───────────────────────────────────────────── - title: "UI Integration Tests" step: true text: | 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) => { 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(); - title: "Run the tests" run: "git add tests/" - run: "nix flake update --override-input calc_module path:../logos-calc-module && nix build .#integration-test -L" code_block: | # Hermetic CI test nix build .#integration-test -L post_text: | 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): ```bash 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 (prose only) ────────────────────────── - title: "Comparison: .rep Interface Patterns" text: | You declare each pattern in the `.rep` and implement it in your `*Backend` (which derives the generated `SimpleSource` + `LogosModuleContext`). There is no hand-written plugin — the `*Plugin`/`*Interface` glue is generated. | Pattern | .rep declaration | Backend C++ (`CalcUiCppBackend`) | 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 from SimpleSource) | `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")` | | **Event-fed PROP** | `PROP(QString last READONLY)` | `onContextReady()`: `modules().dep.on([this](...){ setLast(...); })` | `backend.last` (auto-syncs, no polling) | The last row uses the `LogosModuleContext` surface — typed `modules()` callers and event subscriptions armed in `onContextReady()`. `calc_module` is call-only here; see the [ui-typed-backend doc-test](https://github.com/logos-co/logos-module-builder/blob/main/doctests/ui-typed-backend.test.yaml) for a worked event-driven PROP. # ── Next Steps (prose only) ─────────────────────────────────────────────────── - title: "Next Steps" text: | - 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 [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`. - See [logos-package-manager-ui](https://github.com/logos-co/logos-package-manager-ui) for a production example