mirror of
https://github.com/logos-co/logos-tutorial.git
synced 2026-08-27 19:01:11 +00:00
* 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>
955 lines
49 KiB
YAML
955 lines
49 KiB
YAML
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
|