Files
logos-tutorial/tests/tutorial-qml-ui-app.test.yaml
Dario LipicarandClaude Opus 4.8 9a75e048bf Document + verify event introspection in the tutorials (#62)
* Document + verify event introspection in the tutorials

Mirror the per-method documentation coverage for events. The
wrapping-c-library calc_module already emits a versionReady event; give
it a /// doc comment and assert the whole event pipeline end-to-end.

- wrapping-c-library: document the versionReady event; add a 'List
  events' step asserting 'lm events' shows the signature + both
  description lines; assert logoscore module-info's Events section.
- qml-ui-app: rename the basecamp ui_test driver call openMethods ->
  openInterface and assert the event's description renders on the
  Interface screen (basecamp-interface-docs.png).
- Regenerate both tutorial markdowns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rerun

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:41:23 -03:00

955 lines
49 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: "Tutorial Part 2: Building a QML UI for Your Logos Module"
output: tutorial-qml-ui-app.md
project_name: logos-calc-ui
requires:
- tutorial-wrapping-c-library.test.yaml
release: ""
intro: |
This is Part 2 of the Logos module tutorial series. In [Part 1](tutorial-wrapping-c-library.md) you wrapped a C library as a Logos core module. Now you'll build a **QML user interface** that calls that module — first isolated with `nix run`, then packaged and loaded into `logos-basecamp`.
what_you_build: "A `calc_ui` QML plugin with input fields and buttons that call `calc_module` methods (add, multiply, factorial, fibonacci) through the Logos bridge."
what_you_learn:
- How QML UI plugins work in the Logos platform
- "The `logos.callModule()` bridge that connects QML to core modules"
- The project structure and metadata for a QML plugin
- "How to package and install your UI into `logos-basecamp`"
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 (same as Part 1)
- "Basic familiarity with QML (Qt's declarative UI language)"
sections:
# ── How QML UI Plugins Work (prose only) ────────────────────────────────────
- title: "How QML UI Plugins Work"
text: |
Before writing code, let's understand the architecture:
```
+-------------------+ logos.callModule() +-------------------+
| calc_ui | --------------------------> | calc_module |
| Main.qml (QML) | IPC (Qt Remote Objects) | C++ plugin |
+-------------------+ +-------------------+
^ ^
└──────────────── loaded by ───────────────────────┘
logos-basecamp / logos-standalone-app
```
Key points:
- **No compilation.** A QML plugin is just `.qml` files and a `metadata.json`.
- **Sandboxed.** No network access, no filesystem access outside the module directory.
- **The `logos` bridge** is injected by the host. Call core modules with `logos.callModule("module", "method", [args])`.
- **Entry point** is defined by the required `"view"` field in `metadata.json` (for this tutorial it is `Main.qml`).
# ── Step 1: Scaffold ───────────────────────────────────────────────────────
- title: "Scaffold"
step: true
text: |
Create a new directory and initialise it from the QML module template:
`mkdir logos-calc-ui && cd logos-calc-ui`
steps:
- run: "nix flake init -t github:logos-co/logos-module-builder{release}#ui-qml"
post_text: |
> **Note:** The generated `flake.nix` uses an unpinned `logos-module-builder` URL. Replace it with the pinned version shown in [Step 4](#step-4-update-flakenix) to ensure reproducible builds.
- run: "git init"
- run: "git add -A"
post_text: |
This gives you:
```
logos-calc-ui/
├── flake.nix # Nix build + nix run support
├── metadata.json # Plugin metadata
└── Main.qml # Your UI (starter template)
```
# ── Step 2: Update metadata.json ───────────────────────────────────────────
- title: "Update `metadata.json`"
step: true
text: |
Replace the template contents with your plugin's details. The template may generate an extra `nix` section — keep it as-is, it's used by the builder:
steps:
- file:
path: metadata.json
language: json
content: |
{
"name": "calc_ui",
"version": "1.0.0",
"description": "Calculator UI - QML frontend for the calc_module",
"type": "ui_qml",
"view": "Main.qml",
"dependencies": ["calc_module"],
"category": "tools",
"icon": "icons/calc.png",
"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. The icon is 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: |
The `view` field tells the host which QML file to load for the UI. The `dependencies` field tells the host to load `calc_module` before showing your UI.
> **Naming convention:** Each entry in `dependencies` must match the `name` field in that module's own `metadata.json`. When adding a dependency as a flake input, the **input attribute name** must also match the dependency name — e.g., the input must be called `calc_module`. The URL can point anywhere (a local `path:` or a remote `github:` repo); the attribute name is how the builder resolves dependencies.
# ── Step 3: Write Main.qml ─────────────────────────────────────────────────
- title: "Write `Main.qml`"
step: true
text: |
Replace the starter file with the calculator UI. This demonstrates two communication patterns:
1. **Direct calls** — `logos.callModule()` sends a request and returns the result immediately
2. **Event-based** — `logos.callModule()` fires-and-forgets, the module emits an event, and QML receives it via `logos.onModuleEvent()`
steps:
- file:
path: Main.qml
language: qml
content: |
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
property string result: ""
property string errorText: ""
property string versionFromEvent: ""
// ── Event subscription ────────────────────────────────────
// Subscribe to "versionReady" events pushed from calc_module.
Component.onCompleted: {
if (typeof logos !== "undefined" && logos.onModuleEvent)
logos.onModuleEvent("calc_module", "versionReady")
}
Connections {
target: typeof logos !== "undefined" ? logos : null
function onModuleEventReceived(moduleName, eventName, data) {
if (eventName === "versionReady")
root.versionFromEvent = data[0]
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 24
spacing: 16
// ── Title ──────────────────────────────────────────────
Text {
text: "Logos Calculator"
font.pixelSize: 20
font.weight: Font.DemiBold
color: "#ffffff"
Layout.alignment: Qt.AlignHCenter
}
// ── Pattern 1: Direct call (request -> response) ──────
Text {
text: "Direct calls (logos.callModule -> returns result)"
color: "#8b949e"
font.pixelSize: 12
}
RowLayout {
spacing: 12
Layout.fillWidth: true
TextField {
id: inputA
placeholderText: "a"
Layout.preferredWidth: 80
validator: IntValidator {}
}
TextField {
id: inputB
placeholderText: "b"
Layout.preferredWidth: 80
validator: IntValidator {}
}
Button {
text: "Add"
onClicked: callTwoOp("add", inputA.text, inputB.text)
}
Button {
text: "Multiply"
onClicked: callTwoOp("multiply", inputA.text, inputB.text)
}
}
RowLayout {
spacing: 12
Layout.fillWidth: true
TextField {
id: inputN
placeholderText: "n"
Layout.preferredWidth: 80
validator: IntValidator { bottom: 0 }
}
Button {
text: "Factorial"
onClicked: callOneOp("factorial", inputN.text)
}
Button {
text: "Fibonacci"
onClicked: callOneOp("fibonacci", inputN.text)
}
Button {
text: "libcalc version"
onClicked: callModule("libVersion", [])
}
}
// Direct call result
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
}
}
// ── Pattern 2: Event-based (fire-and-forget -> event) ─
Text {
text: "Event-based (fire-and-forget call -> result via event)"
color: "#8b949e"
font.pixelSize: 12
}
RowLayout {
spacing: 12
Layout.fillWidth: true
Button {
text: "libcalc version (event)"
onClicked: {
if (typeof logos !== "undefined" && logos.callModule)
logos.callModule("calc_module", "libVersionNotify", [])
}
}
}
// Event result
Rectangle {
Layout.fillWidth: true
height: 56
color: "#1a1a2d"
radius: 8
Text {
anchors.centerIn: parent
text: root.versionFromEvent.length > 0
? ("Version (via event): " + root.versionFromEvent)
: "Press the event button — result arrives via event"
color: "#7ab8ff"
font.pixelSize: 15
}
}
Item { Layout.fillHeight: true }
}
// ── Direct call helpers ───────────────────────────────────
function callModule(method, args) {
root.errorText = ""
root.result = ""
if (typeof logos === "undefined" || !logos.callModule) {
root.errorText = "Logos bridge not available"
return
}
root.result = String(logos.callModule("calc_module", method, args))
}
function callTwoOp(method, a, b) {
if (a === "" || b === "") { root.errorText = "Enter values for a and b"; return }
callModule(method, [parseInt(a), parseInt(b)])
}
function callOneOp(method, n) {
if (n === "") { root.errorText = "Enter a value for n"; return }
callModule(method, [parseInt(n)])
}
}
post_text: |
The UI demonstrates two communication patterns:
- **Green section (direct calls):** `logos.callModule("calc_module", "libVersion", [])` sends a request to `calc_module` and returns the result synchronously. Simple request/response.
- **Blue section (event-based):** `logos.callModule("calc_module", "libVersionNotify", [])` calls the module but ignores the return value. Instead, the module emits a `"versionReady"` event, and the QML receives it through the `logos.onModuleEvent()` subscription set up in `Component.onCompleted`.
On the `calc_module` side (Part 1), that event is just the `versionReady(...)` method declared in its `logos_events:` block — the module's `libVersionNotify()` calls it. Nothing about the QML changes regardless of how the backend module is written; the bridge only sees the event name and its arguments.
The `logos` object is injected by the host at runtime.
# ── Step 4: Update flake.nix ───────────────────────────────────────────────
- title: "Update `flake.nix`"
step: true
text: |
The template already has everything wired up. Update the description and add `calc_module` as a dependency input:
steps:
- file:
path: flake.nix
language: nix
content: |
{
description = "Calculator QML UI Plugin for Logos - frontend 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 "Test with nix run" below).
calc_module.url = "path:/path/to/your/calc_module";
};
outputs = inputs@{ logos-module-builder, ... }:
logos-module-builder.lib.mkLogosQmlModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
}
post_text: |
The input attribute name (`calc_module`) 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` (the flake is 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` uses 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 the library is 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) for build instructions.
`mkLogosQmlModule` handles everything — it stages QML files, metadata, and icons into a plugin directory, bundles all module dependencies (direct and transitive) from their LGX packages, and automatically wires up `apps.default` so `nix run .` launches the UI in a standalone window with all required backend modules self-contained. `flakeInputs = inputs` passes all inputs so that dependencies declared in `metadata.json` are resolved automatically.
# ── Step 5: Test with nix run ──────────────────────────────────────────────
- title: "Test with `nix run`"
step: true
steps:
- title: "UI only (layout preview)"
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"
- 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"]
timeout: 15000
- name: "Add button visible"
action: wait_for
texts: ["Add"]
timeout: 5000
- name: "Multiply button visible"
action: wait_for
texts: ["Multiply"]
timeout: 5000
- name: "Factorial button visible"
action: wait_for
texts: ["Factorial"]
timeout: 5000
- name: "Fibonacci button visible"
action: wait_for
texts: ["Fibonacci"]
timeout: 5000
screenshot: "calc-ui.png"
post_text: |
The app opens immediately. No modules are loaded, so clicking buttons shows "Logos bridge not available" — but you can verify the layout and styling look correct.
# ── Step 5b: Full functionality (with modules) ───────────────────────────
# Requires ../logos-calc-module from Part 1. Use --phase modules to enable.
- title: "Full functionality (with modules)"
step: true
text: |
The standalone app automatically bundles and loads all module dependencies declared in `metadata.json`. To test with your local `calc_module` from Part 1, you first need to make sure it has been built and its shared library (`.so` on Linux, `.dylib` on macOS) is present.
steps:
- title: "Ensure `calc_module` is built"
text: |
Go back to your `logos-calc-module` directory and verify the shared library exists:
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"
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
post_text: |
Also make sure the module itself builds successfully:
- run: "cd ../logos-calc-module && git add -A && nix build && cd ../logos-calc-ui"
code_block: |
cd ../logos-calc-module
git add -A
nix build
cd ../logos-calc-ui
post_text: |
The `nix build` produces `result/lib/calc_module_plugin.so` (or `.dylib`), which is the compiled Qt plugin. The `lib/libcalc.so` (or `.dylib`) inside the source tree is the underlying C library that gets linked in during the build.
- title: "Option A: Use `--override-input` (quick, no flake.nix edits)"
text: |
You can point `calc_module` at your local checkout for a single command, without touching `flake.nix` or its lock — handy for a one-off run or when the input is set to a `github:` URL:
ui_test:
launch: "nix run . --override-input calc_module path:../logos-calc-module"
setup:
- "nix build 'github:logos-co/logos-qt-mcp{release}' -o result-mcp"
qt_mcp: "result-mcp"
tests:
- name: "App title visible"
action: wait_for
texts: ["Logos Calculator"]
timeout: 15000
- name: "All operation buttons visible"
action: wait_for
texts: ["Add", "Multiply", "Factorial", "Fibonacci"]
timeout: 5000
post_text: |
This tells nix to resolve the `calc_module` flake input from your local directory instead of from the remote URL. Any changes you've made to `calc_module` locally (including the built `.so`/`.dylib` in `lib/`) are picked up immediately — no need to push to GitHub first.
- title: "Option B: Lock `path:` once, then run normally"
text: |
If you're iterating on both repos side by side, lock `calc_module` to your local checkout once. You can't write `path:../logos-calc-module` directly into `flake.nix` — Nix evaluates the flake from a sandboxed copy, so a relative `..` escapes it and is rejected. Instead, lock it with an `--override-input` (which resolves to an absolute path and stores it in `flake.lock`):
```bash
nix flake update --override-input calc_module path:../logos-calc-module
git add flake.lock
```
After that, plain `nix run .` uses the locked local path — no override needed on each command:
```bash
nix run .
```
Re-run the `nix flake update --override-input …` line whenever you want to re-point or refresh the lock. Switch to a `github:` URL in `flake.nix` when you're ready to pin to a published version.
- title: "Option C: Pin to the remote repo"
text: |
Once `calc_module` is published to its own repo (with the `.so`/`.dylib` committed in `lib/`), point the input at it with a `github:` URL instead of the local `path:` — e.g. `calc_module.url = "github:your-org/your-calc-module";`. Then a plain `nix run .` fetches and builds `calc_module` from the remote:
```bash
nix run .
```
> **Important:** The remote repo must contain the built `.so`/`.dylib` in `lib/` (or the nix build must produce it). If the shared library is missing, the `calc_module` build will fail with linker errors.
Whichever option you choose, clicking **Add**, **Multiply**, **Factorial**, or **Fibonacci** now calls the real module.
# ── Step 6: Using the Logos Design System ──────────────────────────────────
- title: "Using the Logos Design System"
step: true
text: |
`logos-basecamp` (and `logos-standalone-app`) has `logos-design-system` on its QML import path. Use its themed components directly — no extra setup in your module.
```qml
import Logos.Theme
import Logos.Controls
import Logos.Icons // optional: shared icon assets (LogosIcons.search, .install, .refresh, …)
```
### Why use it
Hardcoding colors, font sizes, or rolling your own button means your module looks subtly different from every other module in basecamp, drifts as the design evolves, and re-implements work the design system already does. Using `Logos.Controls` + `Theme` tokens means your module gets the polished look automatically as the design system is updated — no churn on your side.
### What's available
Run the storybook to browse every component interactively with live property editors:
```bash
cd repos/logos-design-system
nix run # or: ws run logos-design-system
```
The sidebar splits components into two sections:
- **Controls** — *designed per Figma, production-ready*. Use these directly. Examples: `LogosButton`, `LogosBadge`, `LogosCheckbox`, `LogosComboBox`, `LogosIconButton`, `LogosPaginator`, `LogosSearchBar`, `LogosTabBar` / `LogosTabButton`, `LogosTable` / `LogosTableColumn`, `LogosText`, `LogosTextField`, `LogosToolTip`.
- **Controls (not designed)** — *placeholders with stable APIs but unstyled visuals*. Functional, you can ship with them, and you'll inherit the polished look automatically when each gets its design pass — no QML changes on your side. Examples: `LogosDialog`, `LogosDrawer`, `LogosFrame`, `LogosGroupBox`, `LogosItemDelegate`, `LogosMenu`, `LogosProgressBar`, `LogosRadioButton`, `LogosScrollBar` / `LogosScrollView`, `LogosSlider`, `LogosSpinBox`, `LogosSpinner`, `LogosStackView`, `LogosSwitch`, `LogosTextArea`, `LogosToolBar`.
Each storybook page exposes a `designed: true/false` flag if you want to see at a glance which it is.
### Replace raw Qt controls with Logos equivalents
```qml
// Instead of Button:
LogosButton {
text: qsTr("Add")
onClicked: callTwoOp("add", inputA.text, inputB.text)
}
// Instead of TextField:
LogosTextField {
id: inputA
placeholderText: qsTr("a")
}
// Use theme colors instead of hardcoded hex values:
Rectangle {
color: Theme.palette.backgroundSecondary
Text { color: Theme.palette.text }
}
```
### Theme tokens — avoid hardcoding magic numbers
```qml
// Palette — Theme.palette.*
// background, backgroundSecondary, backgroundMuted, surface,
// text, textSecondary, textMuted, textTertiary,
// border, borderSubtle, primary, success, warning, error, info, hover, pressed, …
// Spacing — Theme.spacing.*
// tiny, small, medium, large, xlarge, xxlarge,
// radiusSmall, radiusMedium, radiusLarge
// Typography — Theme.typography.*
// pageTitleText (36), titleText (30), panelTitleText (24),
// subtitleText (16), primaryText (14), secondaryText (12),
// weightRegular (400), weightMedium (500), weightBold (700),
// publicSans (font family)
// Icons — Logos.Icons.LogosIcons.*
// arrowLeft, arrowRight, refresh, install, trash, more, search, …
```
If a token you need is missing, file a feature issue — don't inline a hex literal or a magic number; that just stores up drift.
### 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 7: Load in logos-basecamp ─────────────────────────────────────────
- title: "Load in `logos-basecamp`"
step: true
steps:
- title: "Bundle as LGX packages"
text: |
Create `.lgx` packages for both dev and portable variants. Use `--out-link` to avoid overwriting the `result` symlink:
run: "cd ../logos-calc-module && nix build '.#lgx' --out-link result-lgx && nix build '.#lgx-portable' --out-link result-lgx-portable && cd ../logos-calc-ui"
code_block: |
# Package calc_module (from Part 1)
cd ../logos-calc-module
nix build '.#lgx' --out-link result-lgx
nix build '.#lgx-portable' --out-link result-lgx-portable
# Package the QML UI plugin
cd ../logos-calc-ui
nix build '.#lgx' --out-link result-lgx
nix build '.#lgx-portable' --out-link result-lgx-portable
extra_run:
run: "nix flake update --override-input calc_module path:../logos-calc-module && nix build '.#lgx' --out-link result-lgx && nix build '.#lgx-portable' --out-link result-lgx-portable"
code_block: |
nix build '.#lgx' --out-link result-lgx
nix build '.#lgx-portable' --out-link result-lgx-portable
post_text: |
> **Re-locking note:** earlier steps rebuilt `calc_module` and dropped `result-lgx` links inside its directory, so its on-disk contents changed since you first locked it. Because `calc_module` is a local `path:` input, re-run `nix flake update --override-input calc_module path:../logos-calc-module` before building so the lock matches the current contents (a stricter Nix otherwise rejects the stale hash).
post_text: |
> For more bundling options (standalone bundler syntax, cross-platform packaging), see the [Developer Guide — Bundling with nix-bundle-lgx](logos-developer-guide.md#32-bundling-with-nix-bundle-lgx).
- title: "Build logos-basecamp"
text: |
Build the basecamp desktop shell:
run: "nix build 'github:logos-co/logos-basecamp{release}' -o basecamp-result"
post_text: |
Basecamp manages its own per-user data directory and preinstalls its bundled modules (`main_ui`, `package_manager`, …) from the build. It does **not** accept `--modules-dir` / `--ui-plugins-dir` flags; instead you point it at a data directory with `--user-dir` (or the `LOGOS_USER_DIR` env var), and it reads installed core modules from `<dir>/modules` and UI plugins from `<dir>/plugins` — exactly the directories `lgpm` writes to.
For this tutorial we use an explicit data directory, `basecamp-data`, so the install location is deterministic (no `~/Library/Application Support/Logos/LogosBasecampDev` or `~/.local/share/Logos/LogosBasecampDev` platform-path guessing).
- title: "Build the `lgpm` package manager CLI"
text: |
`lgpm` installs `.lgx` packages into a modules/plugins directory:
run: "nix build 'github:logos-co/logos-package-manager{release}#cli' --out-link ./pm"
- title: "Create the data directory"
text: |
Make a fresh, isolated data directory with the `modules/` and `plugins/` subdirectories basecamp expects:
run: "rm -rf basecamp-data && mkdir -p basecamp-data/modules basecamp-data/plugins"
- title: "Install the core module"
text: |
Install `calc_module` (the LGX you built above) into the data directory's `modules/`:
run: "./pm/bin/lgpm --modules-dir basecamp-data/modules install --file ../logos-calc-module/result-lgx/*.lgx"
- title: "Install the UI plugin"
text: |
Install `calc_ui` into the data directory's `plugins/`:
run: "./pm/bin/lgpm --ui-plugins-dir basecamp-data/plugins install --file result-lgx/*.lgx"
- title: "Launch basecamp and use the calculator"
text: |
Launch basecamp pointed at that data directory. The `calc_ui` plugin appears in the sidebar alongside the built-in modules — open it, enter two numbers, and press **Add** to call `calc_module` through basecamp:
ui_test:
launch: "./basecamp-result/bin/LogosBasecamp --user-dir $PWD/basecamp-data"
setup:
# The QML inspector (built into the default basecamp build) is what
# lets us drive the UI; build the test framework it talks to.
- "nix build 'github:logos-co/logos-qt-mcp{release}' -o result-mcp"
qt_mcp: "result-mcp"
tests:
# Wait for the shell to finish booting. "Dashboard" is basecamp's
# default view and is always present once the window is up.
- name: "Basecamp shell loads"
action: wait_for
texts: ["Dashboard"]
timeout: 60000
screenshot: "basecamp-load.png"
# The sidebar is icon-only, so the `calc_ui` label isn't visible
# text — but the framework can click the sidebar button by its
# name (its `text` property). Clicking loads the plugin's view.
- name: "Open the calc_ui plugin from the sidebar"
action: click
target: "calc_ui"
# Now assert against the plugin's own rendered content.
- name: "Calculator view renders"
action: wait_for
texts: ["Logos Calculator", "Add", "Multiply"]
timeout: 15000
screenshot: "basecamp-load-calculator.png"
- name: "Enter first operand"
action: set_text
find_by: "placeholderText"
find_value: "a"
value: "3"
- name: "Enter second operand"
action: set_text
find_by: "placeholderText"
find_value: "b"
value: "5"
- name: "Click Add"
action: click
target: "Add"
- name: "calc_module returns 3 + 5 = 8"
action: wait_for
texts: ["8"]
timeout: 10000
screenshot: "basecamp-calc-installed.png"
# ── Verify the `///` method + event docs surface on the Interface screen ──
- name: "Open the Modules system view"
text: |
The doc comments you wrote on `calc_module`'s methods and events in
Part 1 also surface in basecamp. Open **Modules → Core Modules**, then
open `calc_module`'s **Interface** — each method and event shows its
`description`.
action: click
target: "Modules"
- name: "Switch to the Core Modules tab"
action: click
target: "Core Modules"
- name: "calc_module is listed"
action: wait_for
texts: ["calc_module"]
timeout: 15000
# Every module row's "Interface" button shares the same label, so open
# calc_module's Interface screen by name via the view's openInterface()
# helper (equivalent to clicking its button).
- name: "Open calc_module's Interface screen"
action: call_method
find_by: "objectName"
find_value: "coreModulesView"
method: "openInterface"
args: ["calc_module"]
- name: "Method and event descriptions render (single- and multi-line)"
action: wait_for
texts:
- "Adds two integers and returns the sum."
- "Computes the factorial n! of a non-negative integer.\nDefined as n * (n-1) * ... * 1, with 0! = 1."
- "Emitted by libVersionNotify() once the library version is known.\nCarries the version string read from libcalc."
timeout: 15000
screenshot: "basecamp-interface-docs.png"
post_text: |
The result `8` comes back from `calc_module`: pressing **Add** calls `logos.callModule("calc_module", "add", [3, 5])`, which basecamp routes to your core module and back to the QML view. Both modules — the `calc_module` core plugin and the `calc_ui` view plugin — are loaded from the `basecamp-data` directory you installed them into.
The **Interface** screen (Modules → Core Modules → *Interface*) lists every method **and event** with the `description` from its doc comment — the same docs `lm` and `logoscore module-info` showed in Part 1, here in the GUI. Multi-line `///` comments render as multiple lines, exactly as written.
The sidebar labels each UI plugin by its `name` from `metadata.json`, which is why the tab reads `calc_ui`.
- title: "Portable basecamp build (optional)"
text: |
The dev build above depends on nix store paths at runtime. For a self-contained portable build that works without nix:
run: "nix build 'github:logos-co/logos-basecamp{release}#bin-bundle-dir' -o basecamp-portable"
post_text: |
```bash
# Launch once to preinstall bundled modules
./basecamp-portable/bin/LogosBasecamp
```
The portable build uses a different data directory (`LogosBasecamp` instead of `LogosBasecampDev`). Set `BASECAMP_DIR` to your platform's path:
```bash
# macOS:
BASECAMP_DIR="$HOME/Library/Application Support/Logos/LogosBasecamp"
# Linux:
BASECAMP_DIR="$HOME/.local/share/Logos/LogosBasecamp"
```
Install your modules using the **portable** `.lgx` variants:
```bash
# Install core module (use portable variant)
./pm/bin/lgpm --modules-dir "$BASECAMP_DIR/modules" \
install --file ../logos-calc-module/result-lgx-portable/*.lgx
# Install UI plugin (use portable variant)
./pm/bin/lgpm --ui-plugins-dir "$BASECAMP_DIR/plugins" \
install --file result-lgx-portable/*.lgx
# Launch
./basecamp-portable/bin/LogosBasecamp
```
> **Important:** Portable basecamp requires portable `.lgx` variants (`result-lgx-portable`), and the dev build requires dev variants (`result-lgx`). Mixing them will cause loading failures.
- title: "Install via logos-basecamp UI"
text: |
Instead of using `lgpm` on the command line, you can install modules through the basecamp UI:
1. Launch `logos-basecamp`
2. Go to **Package Manager**
3. Click **Install from file**
4. Select `../logos-calc-module/result-lgx/*.lgx` — installs `calc_module`
5. Repeat for `result-lgx/*.lgx` — installs `calc_ui`
A `calc_ui` tab appears in the sidebar (UI plugins are labelled by their `name` from `metadata.json`). Clicking it loads your `Main.qml`.
- title: "Live reloading with `logos-standalone-app`"
text: |
For QML iteration, set `DEV_QML_PATH` to the directory that contains your view entry file (the basename from `metadata.json` `view` must exist under that directory). For this tutorial's layout (`view`: `Main.qml` at repo root):
```bash
DEV_QML_PATH=$PWD 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 in `Main.qml` are picked up on the next relaunch without you having to manually 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 ./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. See `repos/logos-standalone-app/README.md`.
> This does not work with `logos-basecamp`. Basecamp loads QML plugins from its own data directory, so changes to your source files are not reflected until you rebuild and reinstall the `.lgx` package.
- title: "Testing without any runtime"
text: |
You can open `Main.qml` in any QML viewer (e.g., `qml` from Qt) to test the layout.
#### Install
You'll need to have QML and any included modules (`QtQuick` and submodules `Controls`, and `Layout`).
Eg, to simply install on linux (apt package manager):
```bash
sudo apt install qml-qt6 qml6-module-qtquick qml6-module-qtquick-controls qml6-module-qtquick-layouts
```
#### Viewing the QML
The `logos` bridge won't be available, so clicking buttons will show "Logos bridge not available" -- but you can verify the layout and styling work correctly.
```bash
# If you have Qt and included modules installed
# macOS:
qml Main.qml
# Linux:
qml6 Main.qml
```
# ── Step 8: UI Integration Tests ─────────────────────────────────────────
- title: "UI Integration Tests"
step: true
text: |
You can add automated UI tests that verify your QML plugin renders correctly. The test infrastructure is built into `logos-module-builder` — just add `.mjs` test files to a `tests/` directory and you get `nix build .#integration-test` for free.
Tests use the [logos-qt-mcp](https://github.com/logos-co/logos-qt-mcp) test framework, which connects 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: loads and shows title", async (app) => {
await app.waitFor(
async () => {
await app.expectTexts(["Logos Calculator"]);
},
{ timeout: 15000, interval: 500, description: "calc_ui to load" },
);
});
test("calc_ui: add button visible", async (app) => {
await app.expectTexts(["Add"]);
});
test("calc_ui: click add shows validation", async (app) => {
await app.click("Add");
await app.waitFor(
async () => {
await app.expectTexts(["Enter values for a and b"]);
},
{ timeout: 5000, interval: 500, description: "validation message to appear" },
);
});
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: |
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/`.
You can have multiple test files (e.g., `tests/smoke.mjs`, `tests/interactions.mjs`) — they are all discovered and run automatically.
To run tests interactively (against an already-running app):
```bash
nix build .#test-framework -o result-mcp
nix run . # start the app with inspector on :3768
node tests/ui-tests.mjs # in another terminal
```
# ── Known Limitations (prose only) ─────────────────────────────────────────
- title: "Known Limitations"
text: |
### QML-to-C++ type coercion
When calling C++ module methods from QML via `logos.callModule()`, arguments are passed through IPC as `QVariant` values. The runtime automatically coerces mismatched types to match the target method signature — for example, a `double` sent from QML will be converted to `int` if the method expects an integer, and numeric strings will be converted to their numeric types.
This means you can define your module methods with their natural parameter types and calls from QML will work without manual conversion. In the pure-C++ (`universal`) module from Part 1 that looks like:
```cpp
// In calc_module_impl.h — plain C++, no Qt. The generator maps
// int64_t onto the wire as int; the runtime coerces QML args to match.
int64_t add(int64_t a, int64_t b);
```
> **Note:** Type coercion uses `QVariant::convert()`, which rounds (not truncates) when converting `double` to `int` — e.g., `3.7` becomes `4`.
### QML changes not appearing after rebuild
Qt caches compiled QML on disk. If you update your `Main.qml`, rebuild and reinstall the `.lgx`, but the old UI still appears, the cache is stale. Fix by disabling the cache before launching:
```bash
QML_DISABLE_DISK_CACHE=1 ./basecamp-result/bin/LogosBasecamp
```
### UI module not loading or basecamp behaving unexpectedly
When switching between portable and dev builds of basecamp, or running multiple basecamp instances, the data directory can get into a bad state (stale modules, mixed variants, corrupted preinstall). Clear it and let basecamp re-preinstall on next launch:
```bash
# Remove basecamp's data directory
# macOS:
rm -rf ~/Library/Application\ Support/Logos/LogosBasecampDev
# Linux:
rm -rf ~/.local/share/Logos/LogosBasecampDev
# Relaunch — basecamp will re-preinstall its bundled modules
./basecamp-result/bin/LogosBasecamp
```
Then reinstall your custom modules.
# ── Recap (prose only) ─────────────────────────────────────────────────────
- title: "Recap"
text: |
| | Core Module (Part 1) | QML UI Plugin (Part 2) |
| ------------------- | ------------------------------------------------------ | ----------------------------- |
| Language | C++ (plain `*_impl` class) | QML / JavaScript |
| Files | `*_impl.h/.cpp`, `CMakeLists.txt`, `metadata.json` | `Main.qml`, `metadata.json` |
| Compilation | Yes (CMake → `.so`) | No (file copy) |
| `metadata.type` | `"core"` (`interface: "universal"`) | `"ui_qml"` |
| Test command | `logoscore -D -m ./modules` + `call` | `nix run .` |
| Calls other modules | Via `LogosModuleContext` `modules()` (C++) | Via `logos.callModule()` (JS) |
# ── What's Next (prose only) ───────────────────────────────────────────────
- title: "What's Next"
text: |
- **Add more methods** to `calc_module` and call them from QML
- **Use Logos Design System** styled components for consistent look and feel
- **Build a C++ UI module** for cases where QML sandboxing is too restrictive — see [Developer Guide](logos-developer-guide.md), Section 7.2