diff --git a/outputs/images/calc-cpp-buttons.png b/outputs/images/calc-cpp-buttons.png index 858f5b1..a587d00 100644 Binary files a/outputs/images/calc-cpp-buttons.png and b/outputs/images/calc-cpp-buttons.png differ diff --git a/outputs/images/calc-cpp-event.png b/outputs/images/calc-cpp-event.png new file mode 100644 index 0000000..7c45cf4 Binary files /dev/null and b/outputs/images/calc-cpp-event.png differ diff --git a/outputs/images/calc-cpp-result.png b/outputs/images/calc-cpp-result.png index 69f3a4d..30222c3 100644 Binary files a/outputs/images/calc-cpp-result.png and b/outputs/images/calc-cpp-result.png differ diff --git a/outputs/logos-calc-ui-cpp/src/calc_ui_cpp.rep b/outputs/logos-calc-ui-cpp/src/calc_ui_cpp.rep index 3d2677f..f7e75c5 100644 --- a/outputs/logos-calc-ui-cpp/src/calc_ui_cpp.rep +++ b/outputs/logos-calc-ui-cpp/src/calc_ui_cpp.rep @@ -1,8 +1,33 @@ class CalcUiCpp { + // ── SLOTs — call-and-return; each reply reaches QML via logos.watch() ── 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()) + + // Void slot — fire-and-forget. Asks calc_module to (re-)announce + // its version as a `versionReady` event; there's no return value + // to await, the answer comes back through the PROP below. + SLOT(void announceVersion()) + + // ── PROPs — auto-synced backend → every QML replica, no polling ── + // QString, event-fed: the typed `versionReady` subscription the + // backend arms in onContextReady() writes it. Starts empty. + PROP(QString versionEvent="" READONLY) + + // int, slot-driven: the backend bumps it after each calculation, + // so the view shows a live tally without ever polling. + PROP(int computeCount=0 READONLY) + + // int, READWRITE: a memory register the QML view both *reads* and + // *writes* (Store / Clear buttons), and the backend may set too. + // A write round-trips QML → replica → source → back to every replica. + PROP(int memory=0 READWRITE) + + // ── SIGNAL — backend → view push, distinct from a return value ── + // Emitted after each calculation. QML catches it with a + // Connections block (not logos.watch(), not a PROP read). + SIGNAL(computed(QString op, int result)) } diff --git a/outputs/logos-calc-ui-cpp/src/calc_ui_cpp_backend.cpp b/outputs/logos-calc-ui-cpp/src/calc_ui_cpp_backend.cpp index 6ce588b..6c118ad 100644 --- a/outputs/logos-calc-ui-cpp/src/calc_ui_cpp_backend.cpp +++ b/outputs/logos-calc-ui-cpp/src/calc_ui_cpp_backend.cpp @@ -6,22 +6,30 @@ int CalcUiCppBackend::add(int a, int b) { - return modules().calc_module.add(a, b); + int result = modules().calc_module.add(a, b); + record("add", result); + return result; } int CalcUiCppBackend::multiply(int a, int b) { - return modules().calc_module.multiply(a, b); + int result = modules().calc_module.multiply(a, b); + record("multiply", result); + return result; } int CalcUiCppBackend::factorial(int n) { - return modules().calc_module.factorial(n); + int result = modules().calc_module.factorial(n); + record("factorial", result); + return result; } int CalcUiCppBackend::fibonacci(int n) { - return modules().calc_module.fibonacci(n); + int result = modules().calc_module.fibonacci(n); + record("fibonacci", result); + return result; } QString CalcUiCppBackend::libVersion() @@ -30,3 +38,38 @@ QString CalcUiCppBackend::libVersion() // (api-style qt), matching the .rep slot — no conversion needed. return modules().calc_module.libVersion(); } + +void CalcUiCppBackend::record(const QString& op, int result) +{ + // PROP: bump the slot-driven counter. setComputeCount() is the + // generated setter; Qt Remote Objects syncs the new value to every + // replica, so the view's "Computations" label updates with no polling. + setComputeCount(computeCount() + 1); + + // SIGNAL: a backend → view push, distinct from the return value the + // QML side gets via logos.watch(). `computed` is declared on the + // generated SimpleSource, so we just emit it; the typed replica + // re-emits it and the view's Connections block catches it. + emit computed(op, result); +} + +void CalcUiCppBackend::announceVersion() +{ + // Fire-and-forget call into calc_module: it looks up the library + // version and emits it as a `versionReady` event. We don't read a + // return value here — the event comes back through the subscription + // armed in onContextReady() below. + modules().calc_module.libVersionNotify(); +} + +void CalcUiCppBackend::onContextReady() +{ + // Typed module-event subscription. `versionReady` is calc_module's + // event (Part 1's `logos_events:` block); the generated wrapper + // exposes it as on + a Qt-typed callback (QString, because a + // UI plugin is api-style qt). Push each payload into the versionEvent + // PROP — Qt Remote Objects then auto-syncs it to the QML replica. + modules().calc_module.onVersionReady([this](const QString& version) { + setVersionEvent(version); + }); +} diff --git a/outputs/logos-calc-ui-cpp/src/calc_ui_cpp_backend.h b/outputs/logos-calc-ui-cpp/src/calc_ui_cpp_backend.h index 35dad83..4201e39 100644 --- a/outputs/logos-calc-ui-cpp/src/calc_ui_cpp_backend.h +++ b/outputs/logos-calc-ui-cpp/src/calc_ui_cpp_backend.h @@ -21,4 +21,19 @@ public: int factorial(int n) override; int fibonacci(int n) override; QString libVersion() override; + + // Tells calc_module to emit its `versionReady` event. + void announceVersion() override; + + // Fires once when ui-host hands the plugin its LogosAPI — the + // typed dependency surface is live, so we arm the event + // subscription here (before the view's first call). + void onContextReady() override; + +private: + // Feeds the non-slot surfaces of the .rep after each calculation: + // bumps the computeCount PROP (setComputeCount, generated) and + // emits the `computed` SIGNAL. The READWRITE `memory` PROP is + // driven from QML, so the backend doesn't have to touch it. + void record(const QString& op, int result); }; diff --git a/outputs/logos-calc-ui-cpp/src/qml/Main.qml b/outputs/logos-calc-ui-cpp/src/qml/Main.qml index 745c699..8b6379d 100644 --- a/outputs/logos-calc-ui-cpp/src/qml/Main.qml +++ b/outputs/logos-calc-ui-cpp/src/qml/Main.qml @@ -8,6 +8,9 @@ Item { property string result: "" property string errorText: "" + // Last payload from the backend's `computed` SIGNAL (see Connections below). + property string lastSignal: "(none)" + // Typed replica of the backend running in ui-host (generated from calc_ui_cpp.rep). readonly property var backend: logos.module("calc_ui_cpp") @@ -29,6 +32,17 @@ Item { root.ready = root.backend !== null && logos.isViewModuleReady("calc_ui_cpp") } + // SIGNAL from the .rep: the backend emits `computed(op, result)` after + // each calculation. The typed replica re-emits it, so we catch it with + // a Connections block — no logos.watch(), no property read. This is the + // backend → view push path, distinct from the slot return value above. + Connections { + target: root.backend + function onComputed(op, result) { + root.lastSignal = op + " = " + result + } + } + // logos.watch() delivers the result of a replica slot call via callbacks. // No QtRemoteObjects import needed — the bridge handles it. function callCalc(method, args) { @@ -123,6 +137,15 @@ Item { enabled: root.ready onClicked: root.callCalc("libVersion", []) } + + Button { + // Fires the event path: asks calc_module to emit + // versionReady. No logos.watch() — the result comes + // back through the versionEvent PROP, not a return value. + text: "Announce version (event)" + enabled: root.ready + onClicked: root.backend.announceVersion() + } } Rectangle { @@ -140,6 +163,61 @@ Item { } } + // Slot-driven PROP: bumped by the backend's record() after each + // calculation. A plain property read — auto-syncs, no polling. + Text { + text: "Computations: " + ((root.ready && root.backend) ? root.backend.computeCount : 0) + color: "#cdd6f4" + font.pixelSize: 14 + Layout.alignment: Qt.AlignHCenter + } + + // SIGNAL payload, captured by the Connections block above. + Text { + text: "Last op (signal): " + root.lastSignal + color: "#94e2d5" + font.pixelSize: 14 + Layout.alignment: Qt.AlignHCenter + } + + // READWRITE PROP: the memory register. The label *reads* + // backend.memory; the buttons *write* it. A write round-trips + // QML → replica → backend source → back to every replica, so the + // label updates once the new value syncs home. + RowLayout { + spacing: 12 + Layout.alignment: Qt.AlignHCenter + + Text { + text: "Memory: " + ((root.ready && root.backend) ? root.backend.memory : 0) + color: "#cdd6f4" + font.pixelSize: 14 + } + + Button { + text: "Store (MS)" + enabled: root.ready + onClicked: root.backend.memory = parseInt(root.result) || 0 + } + + Button { + text: "Clear (MC)" + enabled: root.ready + onClicked: root.backend.memory = 0 + } + } + + // Event-fed label: the versionEvent PROP auto-syncs from the + // backend's typed versionReady subscription. No polling — it + // updates the moment calc_module emits. + Text { + readonly property string ev: (root.ready && root.backend) ? root.backend.versionEvent : "" + text: "Version event: " + (ev.length > 0 ? ev : "(none yet)") + color: "#f9e2af" + font.pixelSize: 15 + Layout.alignment: Qt.AlignHCenter + } + Item { Layout.fillHeight: true } } } diff --git a/outputs/tutorial-cpp-ui-app.md b/outputs/tutorial-cpp-ui-app.md index 7350b69..32f67c7 100644 --- a/outputs/tutorial-cpp-ui-app.md +++ b/outputs/tutorial-cpp-ui-app.md @@ -6,9 +6,9 @@ You'll use the **universal authoring model**: set `"interface": "universal"` in **What you'll build:** A `calc_ui_cpp` module with: -- A `.rep` file defining the remote interface (slots) — the one Qt-typed contract you author +- 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 calls the backend via a typed replica using `logos.watch()` +- 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** - 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. @@ -75,7 +75,9 @@ And because `metadata.json` sets `"interface": "universal"`, the builder also ge - **`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(...)`), 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. `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). +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(...)`), 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. ## Step 1: Scaffold @@ -161,20 +163,49 @@ Create `src/calc_ui_cpp.rep`: ```rep class CalcUiCpp { + // ── SLOTs — call-and-return; each reply reaches QML via logos.watch() ── 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()) + + // Void slot — fire-and-forget. Asks calc_module to (re-)announce + // its version as a `versionReady` event; there's no return value + // to await, the answer comes back through the PROP below. + SLOT(void announceVersion()) + + // ── PROPs — auto-synced backend → every QML replica, no polling ── + // QString, event-fed: the typed `versionReady` subscription the + // backend arms in onContextReady() writes it. Starts empty. + PROP(QString versionEvent="" READONLY) + + // int, slot-driven: the backend bumps it after each calculation, + // so the view shows a live tally without ever polling. + PROP(int computeCount=0 READONLY) + + // int, READWRITE: a memory register the QML view both *reads* and + // *writes* (Store / Clear buttons), and the backend may set too. + // A write round-trips QML → replica → source → back to every replica. + PROP(int memory=0 READWRITE) + + // ── SIGNAL — backend → view push, distinct from a return value ── + // Emitted after each calculation. QML catches it with a + // Connections block (not logos.watch(), not a PROP read). + SIGNAL(computed(QString op, int result)) } ``` -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: +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 -- `rep_calc_ui_cpp_replica.h` — `CalcUiCppReplica` with typed methods the QML view calls +- `rep_calc_ui_cpp_source.h` — `CalcUiCppSimpleSource` with virtual slots your `*Backend` overrides, a `set(...)` 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 -**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. +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. --- @@ -248,12 +279,31 @@ public: int factorial(int n) override; int fibonacci(int n) override; QString libVersion() override; + + // Tells calc_module to emit its `versionReady` event. + void announceVersion() override; + + // Fires once when ui-host hands the plugin its LogosAPI — the + // typed dependency surface is live, so we arm the event + // subscription here (before the view's first call). + void onContextReady() override; + +private: + // Feeds the non-slot surfaces of the .rep after each calculation: + // bumps the computeCount PROP (setComputeCount, generated) and + // emits the `computed` SIGNAL. The READWRITE `memory` PROP is + // driven from QML, so the backend doesn't have to touch it. + void record(const QString& op, int result); }; ``` -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. +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`). -`LogosUiPluginContext` 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()`. +Beyond the call-and-return slots, the backend drives the rest of the `.rep` surface: + +- **PROPs** — each gets a generated `set()` 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(...)` 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. ### 5.2 `src/calc_ui_cpp_backend.cpp` @@ -266,22 +316,30 @@ No `Q_OBJECT`, no `Q_PLUGIN_METADATA`, no `initLogos`, no `name()`/`version()` int CalcUiCppBackend::add(int a, int b) { - return modules().calc_module.add(a, b); + int result = modules().calc_module.add(a, b); + record("add", result); + return result; } int CalcUiCppBackend::multiply(int a, int b) { - return modules().calc_module.multiply(a, b); + int result = modules().calc_module.multiply(a, b); + record("multiply", result); + return result; } int CalcUiCppBackend::factorial(int n) { - return modules().calc_module.factorial(n); + int result = modules().calc_module.factorial(n); + record("factorial", result); + return result; } int CalcUiCppBackend::fibonacci(int n) { - return modules().calc_module.fibonacci(n); + int result = modules().calc_module.fibonacci(n); + record("fibonacci", result); + return result; } QString CalcUiCppBackend::libVersion() @@ -290,14 +348,50 @@ QString CalcUiCppBackend::libVersion() // (api-style qt), matching the .rep slot — no conversion needed. return modules().calc_module.libVersion(); } + +void CalcUiCppBackend::record(const QString& op, int result) +{ + // PROP: bump the slot-driven counter. setComputeCount() is the + // generated setter; Qt Remote Objects syncs the new value to every + // replica, so the view's "Computations" label updates with no polling. + setComputeCount(computeCount() + 1); + + // SIGNAL: a backend → view push, distinct from the return value the + // QML side gets via logos.watch(). `computed` is declared on the + // generated SimpleSource, so we just emit it; the typed replica + // re-emits it and the view's Connections block catches it. + emit computed(op, result); +} + +void CalcUiCppBackend::announceVersion() +{ + // Fire-and-forget call into calc_module: it looks up the library + // version and emits it as a `versionReady` event. We don't read a + // return value here — the event comes back through the subscription + // armed in onContextReady() below. + modules().calc_module.libVersionNotify(); +} + +void CalcUiCppBackend::onContextReady() +{ + // Typed module-event subscription. `versionReady` is calc_module's + // event (Part 1's `logos_events:` block); the generated wrapper + // exposes it as on + a Qt-typed callback (QString, because a + // UI plugin is api-style qt). Push each payload into the versionEvent + // PROP — Qt Remote Objects then auto-syncs it to the QML replica. + modules().calc_module.onVersionReady([this](const QString& version) { + setVersionEvent(version); + }); +} ``` 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. +- Each value slot delegates straight to `calc_module` via `modules().calc_module.(...)` — 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().` 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. -- No `initLogos`, no manual `LogosModules` construction — `modules()` is wired by the generated plugin before any slot runs. +- **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. --- @@ -316,6 +410,9 @@ Item { property string result: "" property string errorText: "" + // Last payload from the backend's `computed` SIGNAL (see Connections below). + property string lastSignal: "(none)" + // Typed replica of the backend running in ui-host (generated from calc_ui_cpp.rep). readonly property var backend: logos.module("calc_ui_cpp") @@ -337,6 +434,17 @@ Item { root.ready = root.backend !== null && logos.isViewModuleReady("calc_ui_cpp") } + // SIGNAL from the .rep: the backend emits `computed(op, result)` after + // each calculation. The typed replica re-emits it, so we catch it with + // a Connections block — no logos.watch(), no property read. This is the + // backend → view push path, distinct from the slot return value above. + Connections { + target: root.backend + function onComputed(op, result) { + root.lastSignal = op + " = " + result + } + } + // logos.watch() delivers the result of a replica slot call via callbacks. // No QtRemoteObjects import needed — the bridge handles it. function callCalc(method, args) { @@ -431,6 +539,15 @@ Item { enabled: root.ready onClicked: root.callCalc("libVersion", []) } + + Button { + // Fires the event path: asks calc_module to emit + // versionReady. No logos.watch() — the result comes + // back through the versionEvent PROP, not a return value. + text: "Announce version (event)" + enabled: root.ready + onClicked: root.backend.announceVersion() + } } Rectangle { @@ -448,15 +565,74 @@ Item { } } + // Slot-driven PROP: bumped by the backend's record() after each + // calculation. A plain property read — auto-syncs, no polling. + Text { + text: "Computations: " + ((root.ready && root.backend) ? root.backend.computeCount : 0) + color: "#cdd6f4" + font.pixelSize: 14 + Layout.alignment: Qt.AlignHCenter + } + + // SIGNAL payload, captured by the Connections block above. + Text { + text: "Last op (signal): " + root.lastSignal + color: "#94e2d5" + font.pixelSize: 14 + Layout.alignment: Qt.AlignHCenter + } + + // READWRITE PROP: the memory register. The label *reads* + // backend.memory; the buttons *write* it. A write round-trips + // QML → replica → backend source → back to every replica, so the + // label updates once the new value syncs home. + RowLayout { + spacing: 12 + Layout.alignment: Qt.AlignHCenter + + Text { + text: "Memory: " + ((root.ready && root.backend) ? root.backend.memory : 0) + color: "#cdd6f4" + font.pixelSize: 14 + } + + Button { + text: "Store (MS)" + enabled: root.ready + onClicked: root.backend.memory = parseInt(root.result) || 0 + } + + Button { + text: "Clear (MC)" + enabled: root.ready + onClicked: root.backend.memory = 0 + } + } + + // Event-fed label: the versionEvent PROP auto-syncs from the + // backend's typed versionReady subscription. No polling — it + // updates the moment calc_module emits. + Text { + readonly property string ev: (root.ready && root.backend) ? root.backend.versionEvent : "" + text: "Version event: " + (ev.length > 0 ? ev : "(none yet)") + color: "#f9e2af" + font.pixelSize: 15 + Layout.alignment: Qt.AlignHCenter + } + Item { Layout.fillHeight: true } } } ``` -Key patterns: +Key patterns — one for each surface the `.rep` exposes: -- `logos.module("calc_ui_cpp")` — gets the typed replica (auto-synced properties) -- `logos.watch(backend.add(1, 2), ...)` — SLOT return value as JS Promise +- `logos.module("calc_ui_cpp")` — gets the typed replica (auto-synced properties, callable slots, connectable signals) +- **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 `logos` object is injected by the host at runtime — no `QtRemoteObjects` import needed @@ -606,7 +782,15 @@ nix run . ![Result of 3 + 5 shows 8](images/calc-cpp-result.png) -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. +![Subscribed event payload reaches the view](images/calc-cpp-event.png) + +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. --- @@ -721,22 +905,25 @@ node tests/ui-tests.mjs # in another terminal ## Comparison: .rep Interface Patterns -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. +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: -| 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) | +| 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)` | +| **Void slot** ✓ | `SLOT(void announceVersion())` | `void announceVersion() override { ... }` | `backend.announceVersion()` (fire-and-forget, no `watch`) | +| **READONLY PROP** ✓ | `PROP(int computeCount=0 READONLY)` | `setComputeCount(computeCount()+1)` (inherited setter) | `backend.computeCount` (auto-syncs, read-only) | +| **READWRITE PROP** ✓ | `PROP(int memory=0 READWRITE)` | `setMemory(...)` — or let QML write it | `backend.memory` (read) / `backend.memory = 8` (write, round-trips) | +| **Signal** ✓ | `SIGNAL(computed(QString op, int result))`| `emit computed("add", 8)` | `Connections { target: backend; function onComputed(op, result) {...} }` | +| **Event-fed PROP** ✓ | `PROP(QString versionEvent="" READONLY)` | `onContextReady()`: `modules().dep.on([this](...){ setVersionEvent(...); })` | `backend.versionEvent` (auto-syncs, no polling) | +| **Model** | (use Q_PROPERTY on backend) | `Q_PROPERTY(QAbstractItemModel* items ...)` | `logos.model("calc_ui_cpp", "items")` | -The last row uses the `LogosUiPluginContext` surface — Qt-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. +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). ## Next Steps -- Add more `.rep` properties/signals for richer UI state -- Use `logos.model()` for list views backed by `QAbstractItemModel` +- 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.` 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 - 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 diff --git a/tests/tutorial-cpp-ui-app.test.yaml b/tests/tutorial-cpp-ui-app.test.yaml index af34539..ef3b18f 100644 --- a/tests/tutorial-cpp-ui-app.test.yaml +++ b/tests/tutorial-cpp-ui-app.test.yaml @@ -13,9 +13,9 @@ intro: | 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 `.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 calls the backend via a typed replica using `logos.watch()` + - 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** - 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. @@ -82,7 +82,9 @@ sections: - **`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(...)`), 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. `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). + 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(...)`), 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. # ── Step 1: Scaffold ──────────────────────────────────────────────────────── - title: "Scaffold" @@ -171,19 +173,48 @@ sections: content: | class CalcUiCpp { + // ── SLOTs — call-and-return; each reply reaches QML via logos.watch() ── 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()) + + // Void slot — fire-and-forget. Asks calc_module to (re-)announce + // its version as a `versionReady` event; there's no return value + // to await, the answer comes back through the PROP below. + SLOT(void announceVersion()) + + // ── PROPs — auto-synced backend → every QML replica, no polling ── + // QString, event-fed: the typed `versionReady` subscription the + // backend arms in onContextReady() writes it. Starts empty. + PROP(QString versionEvent="" READONLY) + + // int, slot-driven: the backend bumps it after each calculation, + // so the view shows a live tally without ever polling. + PROP(int computeCount=0 READONLY) + + // int, READWRITE: a memory register the QML view both *reads* and + // *writes* (Store / Clear buttons), and the backend may set too. + // A write round-trips QML → replica → source → back to every replica. + PROP(int memory=0 READWRITE) + + // ── SIGNAL — backend → view push, distinct from a return value ── + // Emitted after each calculation. QML catches it with a + // Connections block (not logos.watch(), not a PROP read). + SIGNAL(computed(QString op, int result)) } 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: + 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 - - `rep_calc_ui_cpp_replica.h` — `CalcUiCppReplica` with typed methods the QML view calls + - `rep_calc_ui_cpp_source.h` — `CalcUiCppSimpleSource` with virtual slots your `*Backend` overrides, a `set(...)` 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 - **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. + 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. # ── Step 4: CMakeLists.txt ──────────────────────────────────────────────────── - title: "`CMakeLists.txt`" @@ -261,11 +292,30 @@ sections: int factorial(int n) override; int fibonacci(int n) override; QString libVersion() override; + + // Tells calc_module to emit its `versionReady` event. + void announceVersion() override; + + // Fires once when ui-host hands the plugin its LogosAPI — the + // typed dependency surface is live, so we arm the event + // subscription here (before the view's first call). + void onContextReady() override; + + private: + // Feeds the non-slot surfaces of the .rep after each calculation: + // bumps the computeCount PROP (setComputeCount, generated) and + // emits the `computed` SIGNAL. The READWRITE `memory` PROP is + // driven from QML, so the backend doesn't have to touch it. + void record(const QString& op, int result); }; 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. + 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`). - `LogosUiPluginContext` 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()`. + Beyond the call-and-return slots, the backend drives the rest of the `.rep` surface: + + - **PROPs** — each gets a generated `set()` 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(...)` 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. - title: "`src/calc_ui_cpp_backend.cpp`" file: @@ -280,22 +330,30 @@ sections: int CalcUiCppBackend::add(int a, int b) { - return modules().calc_module.add(a, b); + int result = modules().calc_module.add(a, b); + record("add", result); + return result; } int CalcUiCppBackend::multiply(int a, int b) { - return modules().calc_module.multiply(a, b); + int result = modules().calc_module.multiply(a, b); + record("multiply", result); + return result; } int CalcUiCppBackend::factorial(int n) { - return modules().calc_module.factorial(n); + int result = modules().calc_module.factorial(n); + record("factorial", result); + return result; } int CalcUiCppBackend::fibonacci(int n) { - return modules().calc_module.fibonacci(n); + int result = modules().calc_module.fibonacci(n); + record("fibonacci", result); + return result; } QString CalcUiCppBackend::libVersion() @@ -304,13 +362,49 @@ sections: // (api-style qt), matching the .rep slot — no conversion needed. return modules().calc_module.libVersion(); } + + void CalcUiCppBackend::record(const QString& op, int result) + { + // PROP: bump the slot-driven counter. setComputeCount() is the + // generated setter; Qt Remote Objects syncs the new value to every + // replica, so the view's "Computations" label updates with no polling. + setComputeCount(computeCount() + 1); + + // SIGNAL: a backend → view push, distinct from the return value the + // QML side gets via logos.watch(). `computed` is declared on the + // generated SimpleSource, so we just emit it; the typed replica + // re-emits it and the view's Connections block catches it. + emit computed(op, result); + } + + void CalcUiCppBackend::announceVersion() + { + // Fire-and-forget call into calc_module: it looks up the library + // version and emits it as a `versionReady` event. We don't read a + // return value here — the event comes back through the subscription + // armed in onContextReady() below. + modules().calc_module.libVersionNotify(); + } + + void CalcUiCppBackend::onContextReady() + { + // Typed module-event subscription. `versionReady` is calc_module's + // event (Part 1's `logos_events:` block); the generated wrapper + // exposes it as on + a Qt-typed callback (QString, because a + // UI plugin is api-style qt). Push each payload into the versionEvent + // PROP — Qt Remote Objects then auto-syncs it to the QML replica. + modules().calc_module.onVersionReady([this](const QString& version) { + setVersionEvent(version); + }); + } 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. + - Each value slot delegates straight to `calc_module` via `modules().calc_module.(...)` — 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().` 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. - - No `initLogos`, no manual `LogosModules` construction — `modules()` is wired by the generated plugin before any slot runs. + - **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. # ── Step 6: QML View ────────────────────────────────────────────────────────── - title: "QML View" @@ -332,6 +426,9 @@ sections: property string result: "" property string errorText: "" + // Last payload from the backend's `computed` SIGNAL (see Connections below). + property string lastSignal: "(none)" + // Typed replica of the backend running in ui-host (generated from calc_ui_cpp.rep). readonly property var backend: logos.module("calc_ui_cpp") @@ -353,6 +450,17 @@ sections: root.ready = root.backend !== null && logos.isViewModuleReady("calc_ui_cpp") } + // SIGNAL from the .rep: the backend emits `computed(op, result)` after + // each calculation. The typed replica re-emits it, so we catch it with + // a Connections block — no logos.watch(), no property read. This is the + // backend → view push path, distinct from the slot return value above. + Connections { + target: root.backend + function onComputed(op, result) { + root.lastSignal = op + " = " + result + } + } + // logos.watch() delivers the result of a replica slot call via callbacks. // No QtRemoteObjects import needed — the bridge handles it. function callCalc(method, args) { @@ -447,6 +555,15 @@ sections: enabled: root.ready onClicked: root.callCalc("libVersion", []) } + + Button { + // Fires the event path: asks calc_module to emit + // versionReady. No logos.watch() — the result comes + // back through the versionEvent PROP, not a return value. + text: "Announce version (event)" + enabled: root.ready + onClicked: root.backend.announceVersion() + } } Rectangle { @@ -464,14 +581,73 @@ sections: } } + // Slot-driven PROP: bumped by the backend's record() after each + // calculation. A plain property read — auto-syncs, no polling. + Text { + text: "Computations: " + ((root.ready && root.backend) ? root.backend.computeCount : 0) + color: "#cdd6f4" + font.pixelSize: 14 + Layout.alignment: Qt.AlignHCenter + } + + // SIGNAL payload, captured by the Connections block above. + Text { + text: "Last op (signal): " + root.lastSignal + color: "#94e2d5" + font.pixelSize: 14 + Layout.alignment: Qt.AlignHCenter + } + + // READWRITE PROP: the memory register. The label *reads* + // backend.memory; the buttons *write* it. A write round-trips + // QML → replica → backend source → back to every replica, so the + // label updates once the new value syncs home. + RowLayout { + spacing: 12 + Layout.alignment: Qt.AlignHCenter + + Text { + text: "Memory: " + ((root.ready && root.backend) ? root.backend.memory : 0) + color: "#cdd6f4" + font.pixelSize: 14 + } + + Button { + text: "Store (MS)" + enabled: root.ready + onClicked: root.backend.memory = parseInt(root.result) || 0 + } + + Button { + text: "Clear (MC)" + enabled: root.ready + onClicked: root.backend.memory = 0 + } + } + + // Event-fed label: the versionEvent PROP auto-syncs from the + // backend's typed versionReady subscription. No polling — it + // updates the moment calc_module emits. + Text { + readonly property string ev: (root.ready && root.backend) ? root.backend.versionEvent : "" + text: "Version event: " + (ev.length > 0 ? ev : "(none yet)") + color: "#f9e2af" + font.pixelSize: 15 + Layout.alignment: Qt.AlignHCenter + } + Item { Layout.fillHeight: true } } } post_text: | - Key patterns: + Key patterns — one for each surface the `.rep` exposes: - - `logos.module("calc_ui_cpp")` — gets the typed replica (auto-synced properties) - - `logos.watch(backend.add(1, 2), ...)` — SLOT return value as JS Promise + - `logos.module("calc_ui_cpp")` — gets the typed replica (auto-synced properties, callable slots, connectable signals) + - **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 `logos` object is injected by the host at runtime — no `QtRemoteObjects` import needed @@ -639,8 +815,45 @@ sections: texts: ["8"] timeout: 10000 screenshot: "calc-cpp-result.png" + - name: "Slot-driven READONLY PROP: compute count incremented" + action: wait_for + texts: ["Computations: 1"] + timeout: 5000 + - name: "SIGNAL delivered: computed(op, result) reaches the view" + action: wait_for + texts: ["Last op (signal): add = 8"] + timeout: 5000 + - name: "Memory register starts at zero" + action: wait_for + texts: ["Memory: 0"] + timeout: 5000 + - name: "Write the READWRITE PROP from QML" + action: click + target: "Store (MS)" + - name: "READWRITE PROP round-trips backend → view" + action: wait_for + texts: ["Memory: 8"] + timeout: 10000 + - name: "Event label starts empty" + action: wait_for + texts: ["Version event: (none yet)"] + timeout: 5000 + - name: "Trigger the versionReady event" + action: click + target: "Announce version (event)" + - name: "Subscribed event payload reaches the view" + action: wait_for + texts: ["Version event: 1.0.0"] + timeout: 10000 + screenshot: "calc-cpp-event.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. + 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. # ── Step 10: Live reloading (prose only) ────────────────────────────────────── - title: "Live reloading QML with `DEV_QML_PATH`" @@ -751,23 +964,26 @@ sections: # ── 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` + `LogosUiPluginContext`). There is no hand-written plugin — the `*Plugin`/`*Interface` glue is generated. + 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: - | 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) | + | 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)` | + | **Void slot** ✓ | `SLOT(void announceVersion())` | `void announceVersion() override { ... }` | `backend.announceVersion()` (fire-and-forget, no `watch`) | + | **READONLY PROP** ✓ | `PROP(int computeCount=0 READONLY)` | `setComputeCount(computeCount()+1)` (inherited setter) | `backend.computeCount` (auto-syncs, read-only) | + | **READWRITE PROP** ✓ | `PROP(int memory=0 READWRITE)` | `setMemory(...)` — or let QML write it | `backend.memory` (read) / `backend.memory = 8` (write, round-trips) | + | **Signal** ✓ | `SIGNAL(computed(QString op, int result))`| `emit computed("add", 8)` | `Connections { target: backend; function onComputed(op, result) {...} }` | + | **Event-fed PROP** ✓ | `PROP(QString versionEvent="" READONLY)` | `onContextReady()`: `modules().dep.on([this](...){ setVersionEvent(...); })` | `backend.versionEvent` (auto-syncs, no polling) | + | **Model** | (use Q_PROPERTY on backend) | `Q_PROPERTY(QAbstractItemModel* items ...)` | `logos.model("calc_ui_cpp", "items")` | - The last row uses the `LogosUiPluginContext` surface — Qt-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. + 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). # ── 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` + - 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.` 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 - 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