Merge pull request #50 from logos-co/executable_tutorials

add executable tutorials support; convert tutorial-wrapping-c-library to an executable tutorial
This commit is contained in:
Iuri Matias
2026-05-29 10:36:35 -04:00
committed by GitHub
11 changed files with 5543 additions and 215 deletions
+200
View File
@@ -0,0 +1,200 @@
name: Tutorial Tests
# ──────────────────────────────────────────────────────────────────────────────
# One-time setup required for the clickable report links to work:
#
# 1. Repo Settings → Pages → "Build and deployment" → Source: "Deploy from a
# branch", Branch: `gh-pages` / `(root)`. (The publish-report job creates
# the gh-pages branch on its first run.)
# 2. Nothing else — GITHUB_TOKEN already has the permissions granted below.
#
# Each run publishes the two-column HTML report to:
# https://<owner>.github.io/<repo>/pr-<N>/<os>/ (pull requests)
# https://<owner>.github.io/<repo>/main/<os>/ (pushes to main/master)
# and (for PRs) posts/updates a comment with the links.
#
# Note: pull requests opened from forks get a read-only GITHUB_TOKEN, so the
# Pages push and PR comment are skipped for them — the downloadable artifact is
# still produced. PRs from branches in this repo get the full clickable links.
# ──────────────────────────────────────────────────────────────────────────────
on:
pull_request:
branches: [master, main]
push:
branches: [master, main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
tutorial-tests:
name: Tutorial Tests (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 90
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Setup Cachix
uses: cachix/cachix-action@v15
with:
name: logos-co
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
- name: Install PyYAML
run: pip3 install --break-system-packages pyyaml
- name: Test - QML UI App (includes C Library tutorial via requires)
run: |
# --continue-on-fail so the run walks the whole Part 1 -> 2 -> 3 chain
# and the published report is complete. The job still fails (non-zero
# exit) if any step failed; this only changes whether we stop early.
python3 tools/tutorial_runner.py run \
tests/tutorial-cpp-ui-app.test.yaml \
--verbose \
--continue-on-fail \
--report "${{ runner.temp }}/tutorial-report.html"
- name: Stage report for upload
if: always()
shell: bash
run: |
mkdir -p report-out
# Name it index.html so the published directory URL renders directly.
if [ -f "${{ runner.temp }}/tutorial-report.html" ]; then
cp "${{ runner.temp }}/tutorial-report.html" report-out/index.html
else
echo "<h1>No report produced</h1>" > report-out/index.html
fi
- name: Upload tutorial execution report
if: always()
uses: actions/upload-artifact@v4
with:
name: tutorial-report-${{ matrix.os }}
path: report-out/index.html
if-no-files-found: warn
- name: Verify markdown generation
run: |
python3 tools/tutorial_runner.py generate \
tests/tutorial-wrapping-c-library.test.yaml \
-o /tmp/gen-part1.md
python3 tools/tutorial_runner.py generate \
tests/tutorial-qml-ui-app.test.yaml \
-o /tmp/gen-part2.md
echo "Generated markdown successfully"
publish-report:
name: Publish report to GitHub Pages
needs: tutorial-tests
# Run even when tests fail — a failing run is exactly when you want to open
# the report. Skip on forks, where GITHUB_TOKEN can't push or comment.
if: ${{ always() && github.event.pull_request.head.repo.fork != true }}
runs-on: ubuntu-latest
permissions:
contents: write # push to the gh-pages branch
pull-requests: write # post/update the PR comment
# Serialize Pages pushes so two refs can't race on the gh-pages branch.
concurrency:
group: gh-pages-publish
cancel-in-progress: false
steps:
- name: Download all reports
uses: actions/download-artifact@v4
with:
path: artifacts
# No `name:` → downloads every artifact into artifacts/<name>/...
- name: Arrange site directory
id: arrange
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="pr-${{ github.event.pull_request.number }}"
else
BASE="main"
fi
echo "base=$BASE" >> "$GITHUB_OUTPUT"
mkdir -p "site/$BASE"
found=""
for os in ubuntu-latest macos-latest; do
src="artifacts/tutorial-report-$os/index.html"
if [ -f "$src" ]; then
mkdir -p "site/$BASE/$os"
cp "$src" "site/$BASE/$os/index.html"
found="$found $os"
fi
done
echo "found=$found" >> "$GITHUB_OUTPUT"
# Landing page for this ref linking to each OS report.
{
echo "<!doctype html><meta charset=utf-8>"
echo "<title>Tutorial reports — $BASE</title>"
echo "<style>body{font:16px system-ui;margin:40px;max-width:640px}a{color:#2563eb}</style>"
echo "<h1>Tutorial execution reports</h1>"
echo "<p><strong>$BASE</strong> · commit <code>${GITHUB_SHA::7}</code></p><ul>"
for os in ubuntu-latest macos-latest; do
if [ -d "site/$BASE/$os" ]; then
echo "<li><a href=\"./$os/\">$os</a></li>"
fi
done
echo "</ul>"
} > "site/$BASE/index.html"
- name: Deploy to gh-pages
if: steps.arrange.outputs.found != ''
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./site
keep_files: true # don't wipe other PRs' directories
commit_message: "Publish tutorial report for ${{ steps.arrange.outputs.base }} (${{ github.sha }})"
- name: Comment on PR with report links
if: ${{ github.event_name == 'pull_request' && steps.arrange.outputs.found != '' }}
uses: actions/github-script@v7
with:
script: |
const base = "${{ steps.arrange.outputs.base }}";
const owner = context.repo.owner;
const repo = context.repo.repo;
const root = `https://${owner}.github.io/${repo}/${base}`;
const oses = "${{ steps.arrange.outputs.found }}".trim().split(/\s+/).filter(Boolean);
const links = oses.map(os => `- [\`${os}\` report](${root}/${os}/)`).join("\n");
const marker = "<!-- tutorial-report-links -->";
const body =
`${marker}\n` +
`### 📊 Tutorial execution report\n\n` +
`Rendered tutorial alongside the commands actually run and their output ` +
`(updated each run, commit \`${context.sha.slice(0,7)}\`):\n\n` +
`${links}\n\n` +
`_Pages can take a minute to update after the run finishes._`;
const { data: comments } = await github.rest.issues.listComments({
owner, repo, issue_number: context.issue.number, per_page: 100,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: context.issue.number, body });
}
+29
View File
@@ -20,6 +20,35 @@ Step-by-step tutorials that build on each other. Each creates a working module y
- **logos-dev-boost:** [Scaffolding Modules with logos-dev-boost](tutorial-dev-boost.md) — use the `logos-dev-boost` CLI to auto-generate modules from C library directories. Wraps libcalc (source-only) and sqlcipher (pre-built `.so`), including integration tests that create encrypted databases. Covers `--type module`, `--type full-app`, and `--lib-dir`.
## Executable Tutorials
Tutorials have YAML specs in `tests/` that can be both **executed** (to verify they work) and used to **generate** the `.md` files. See [docs/spec.md](docs/spec.md) for the full format reference.
```bash
# Run a tutorial end-to-end, writing to a directory you can inspect afterwards
python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml \
--workdir /tmp/my-tutorial-test --verbose
# Run only specific phases (scaffold, files, build, inspect, logoscore)
python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml \
--phase scaffold,files,build --verbose
# Re-run a single phase against a previous build
python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml \
--workdir /tmp/my-tutorial-test --phase inspect --verbose
# Generate the .md tutorial from the YAML spec
python3 tools/tutorial_runner.py generate tests/tutorial-wrapping-c-library.test.yaml
# Pin all GitHub URLs to a specific release tag
python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --release tutorial-v2
python3 tools/tutorial_runner.py generate tests/tutorial-wrapping-c-library.test.yaml --release tutorial-v2
```
The `--workdir` flag lets you point the runner at a directory of your choice — all files, builds, and artifacts end up there so you can inspect or re-use them. Without it, a temp directory is created and deleted after the run (use `--keep-workdir` to preserve it).
The `--release` flag (or the `release` field in the YAML) pins all `{release}` placeholders in GitHub URLs to a git tag, so `github:logos-co/repo{release}#output` becomes `github:logos-co/repo/tutorial-v2#output`. Set it to `""` or omit it for latest.
## Example Modules
Working module source code used by the tutorials:
+471
View File
@@ -0,0 +1,471 @@
# Tutorial YAML Spec Format
This document describes the YAML format used by `tools/tutorial_runner.py` to define executable tutorials. Each `.test.yaml` file is the **single source of truth** — it drives both:
- **Execution** (`run`): steps are executed in a temp directory, commands run, outputs verified
- **Markdown generation** (`generate`): a `.md` tutorial is produced from the same YAML
## Quick example
```yaml
name: "My Tutorial"
output: my-tutorial.md
release: ""
intro: |
One-paragraph description of what this tutorial covers.
what_you_build: "A short sentence describing the end result."
what_you_learn:
- First learning objective
- Second learning objective
prerequisites:
- "**Nix** with flakes enabled."
sections:
- title: "Set Up the Project"
phase: scaffold
text: |
Intro paragraph for this section.
steps:
- title: "Create the directory"
run: "mkdir -p my-project"
- title: "Write the config"
text: "Create `config.json`:"
file:
path: config.json
language: json
content: |
{ "name": "example" }
- title: "Build"
run: "nix build"
expect_contains:
- "Build successful"
```
## Top-level fields
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `name` | yes | string | Tutorial title. Used as the `# heading` in generated markdown and in runner output. |
| `output` | no | string | Default output filename for `generate` (relative to the tutorial directory, e.g., `tutorial-wrapping-c-library.md`). Can be overridden with `-o`. |
| `project_name` | no | string | Directory name for this tutorial's project (e.g., `logos-calc-module`). Used when chaining tutorials via `requires:` — each tutorial runs in a subdirectory of a shared parent. Ignored when running standalone. |
| `requires` | no | list of strings | Paths to prerequisite `.test.yaml` specs (relative to the current spec). The runner executes each prerequisite first in a sibling subdirectory (named by its `project_name`), then runs the current tutorial. Prerequisites are resolved **transitively** — if A requires B and B requires C, the run order is C, B, A. Shared prerequisites run once (deduped), and circular `requires:` are reported as an error. This enables cross-tutorial references like `../logos-calc-module`. Requires `project_name` on both the current and prerequisite specs. |
| `intro` | no | string | Introductory paragraph(s). Rendered after the title in the markdown. Supports full markdown. |
| `what_you_build` | no | string | One-line summary prefixed with "**What you'll build:**" in the markdown. |
| `what_you_learn` | no | list of strings | Bullet list prefixed with "**What you'll learn:**". |
| `comparison` | no | string | Free-form markdown block rendered after the learning objectives (useful for comparison tables). |
| `prerequisites` | no | list of strings | Rendered as a bullet list under a prerequisites heading. Each item can contain markdown (code blocks, links, etc.). |
| `release` | no | string | Git tag applied to all `{release}` placeholders in GitHub URLs (e.g., `tutorial-v2`). See [Release tags](#release-tags). |
| `build_overrides` | no | map | Nix `--override-input` flags for the runner. Keys are input names, values are relative paths to local repos. Only affects execution, not generation. |
| `sections` | yes | list | The tutorial content. See below. |
## Sections
Each section becomes a `## heading` in the markdown. Sections with `step: true` get auto-numbered as "Step N: Title".
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `title` | yes | string | Section heading. |
| `step` | no | boolean | If `true`, this section is numbered as "Step N: Title" in markdown and gets a `---` separator after it (except the last section). Sections without `step` render as plain `## Title`. |
| `text` | no | string | Introductory prose rendered before the steps. Supports full markdown (tables, blockquotes, code blocks, etc.). |
| `steps` | no | list | Ordered list of steps. See below. |
Sections without `steps` are prose-only — the `text` is rendered as-is. This is useful for reference sections like "Troubleshooting" or "Common Patterns".
## Steps
Steps are the core building blocks. Each step can combine multiple fields. The rendering order in the generated markdown is:
1. `title``### heading`
2. `text` → prose paragraph
3. `file` → code block with file contents
4. `run` → bash code block
5. `post_text` → prose after the action
6. `extra_run` → additional command block (no heading)
A step without a `title` renders its content inline under the previous heading — useful for continuation content like "Then run:" followed by a code block.
### Step fields
#### `title` (string, optional)
Rendered as a `### heading` in the markdown. Steps without a title don't get a heading — their content flows under the previous step's heading.
#### `text` (string, optional)
Prose rendered before any action. Supports full markdown.
#### `file` (object, optional)
Writes a file to disk during execution and renders it as a code block in the markdown.
| Subfield | Type | Description |
|----------|------|-------------|
| `path` | string | Relative path within the project (e.g., `src/main.cpp`, `lib/libcalc.h`). |
| `content` | string | The file contents. |
| `language` | string | Syntax highlighting hint for the markdown code block. Auto-detected from extension if omitted (`.c``c`, `.h`/`.cpp``cpp`, `.json``json`, `.nix``nix`, etc.). |
| `encoding` | string | Set to `base64` for binary files. Renders as `*Binary file: \`path\`*` instead of a code block. |
**Runner behavior:** Creates parent directories and writes the file to disk.
**Generator behavior:** Renders ` ```language ` code block with the content.
#### `run` (string, optional)
A shell command to execute and display.
| Subfield | Type | Description |
|----------|------|-------------|
| (value) | string | The command to execute. Supports `{ext}` (expands to `so` or `dylib`) and `{shared_flags}` (expands to `-shared -fPIC` or `-dynamiclib`). |
**Runner behavior:** Expands platform placeholders, injects nix overrides if applicable, and runs the command. Checks exit code (0 = pass, non-zero = fail).
**Generator behavior:** Renders the command in a ` ```bash ` block. If `code_block` is present on the step, renders that instead (see below).
#### `code_block` (string, optional)
The exact content to show in the generated markdown, used **instead of** the `run` command. This is for cases where:
- The executed command differs from what readers should see (e.g., `&&`-chained commands displayed as separate lines)
- Platform-specific variants should be shown (Linux and macOS versions)
- The display should include comments, blank lines, or additional context
The runner ignores `code_block` — it only uses `run` for execution.
```yaml
- title: "Build the shared library"
run: "cd lib && gcc {shared_flags} -o libcalc.{ext} libcalc.c && cd .."
code_block: |
cd lib
# Linux
gcc -shared -fPIC -o libcalc.so libcalc.c
# macOS
# gcc -shared -fPIC -o libcalc.dylib libcalc.c
cd ..
```
#### `expect_contains` (list of strings, optional)
Assertions checked by the runner against command output. Not rendered in the markdown.
```yaml
run: "./lm/bin/lm metadata result/lib/calc_module_plugin.{ext}"
expect_contains:
- "Name: calc_module"
- "Version: 1.0.0"
```
**Runner behavior:** Captures stdout+stderr, checks that every string appears in the output.
**Generator behavior:** Ignored — not rendered. Use `post_text` to show expected output to readers.
#### `check_file` (string, optional)
Verifies a file exists. Runner-only, not rendered in the markdown.
```yaml
- check_file: "result/lib/calc_module_plugin.{ext}"
```
**Runner behavior:** Expands `{ext}`, globs for the file, passes if found.
**Generator behavior:** Ignored — not rendered.
#### `ui_test` (object, optional)
Runs headless UI tests against a Qt app using [logos-qt-mcp](https://github.com/logos-co/logos-qt-mcp). The app is launched with `QT_QPA_PLATFORM=offscreen` (no display needed) and tests connect to the QML inspector to verify elements, click buttons, and check results.
Two modes:
- **Launch mode** (preferred): `launch` runs the app as a background process, tests connect to its inspector, app is killed when done. The `launch` command is rendered in the generated markdown.
- **Binary mode**: `build` + `binary` let the test framework manage the app via `--ci`. Not rendered in markdown.
| Subfield | Type | Description |
|----------|------|-------------|
| `launch` | string | Command to launch the app (e.g., `nix run .`). Launched as a background process with offscreen Qt. **Rendered** in generated markdown as a bash code block. |
| `build` | string | Command to build the app binary (binary mode only, not rendered). |
| `binary` | string | Path to the app binary or `nix-app` to auto-resolve from flake (binary mode only). |
| `qt_mcp` | string | Path to the logos-qt-mcp package, relative to workdir (e.g., `result-mcp`). Falls back to `--qt-mcp` CLI flag or `LOGOS_QT_MCP` env var. |
| `setup` | list of strings | Commands to run before testing (e.g., `nix build 'github:logos-co/logos-qt-mcp' -o result-mcp`). |
| `inspector_port` | integer | TCP port for the QML inspector (default: 3768). |
| `tests` | list of objects | Test actions to execute. See below. |
**Test actions:**
| Action | Fields | What it does |
|--------|--------|--------------|
| `click` | `target` | Find element by text and click it |
| `wait_for` | `texts`, `timeout` (ms, default 10000), `name` | Poll until all texts are visible |
| `expect_texts` | `texts` | Assert all texts are visible now |
| `set_text` | `find_by`, `find_value`, `value` | Find element by property and set its `text` property |
| `sleep` | `ms` | Wait a fixed duration |
**Runner behavior (launch mode):** Runs setup commands, launches the app in the background with `QT_QPA_PLATFORM=offscreen`, waits for the QML inspector to be available, generates a `.mjs` test file, runs it, then kills the app. Reports pass/fail.
**Runner behavior (binary mode):** Runs setup + build, generates a `.mjs` test file, runs it via `node test.mjs --ci <binary> --verbose`. Reports pass/fail.
**Generator behavior:** In launch mode, `launch` is rendered as a ` ```bash ` code block. In binary mode, nothing is rendered. Use `text:` and `post_text:` for additional user-facing prose.
```yaml
# Launch mode (preferred) — what's shown is what's executed
- ui_test:
launch: "nix run ."
setup:
- "nix build 'github:logos-co/logos-qt-mcp' -o result-mcp"
qt_mcp: "result-mcp"
tests:
- name: "Title visible"
action: wait_for
texts: ["Logos Calculator"]
timeout: 15000
- name: "Enter number"
action: set_text
find_by: "placeholderText"
find_value: "a"
value: "3"
- name: "Click Add"
action: click
target: "Add"
- name: "Result shows 3"
action: wait_for
texts: ["3"]
timeout: 10000
```
#### `post_text` (string, optional)
Prose rendered **after** the step's action (file, run). Supports full markdown including code blocks, tables, blockquotes.
Use this for:
- Expected output blocks
- Explanations of what just happened
- Callout boxes and tips
```yaml
- title: "View metadata"
run: "./lm/bin/lm metadata result/lib/plugin.{ext}"
post_text: |
Output:
```
Plugin Metadata:
================
Name: my_module
Version: 1.0.0
```
```
#### `extra_run` (object, optional)
A continuation command rendered under the same step heading (no separate `###`). Useful when a step has two related commands (e.g., build then verify).
| Subfield | Type | Description |
|----------|------|-------------|
| `run` | string | Command to execute. |
| `code_block` | string | Display override (same as step-level `code_block`). |
| `post_text` | string | Prose after the extra command. |
```yaml
- title: "Build the shared library"
run: "cd lib && gcc {shared_flags} -o libcalc.{ext} libcalc.c && cd .."
code_block: |
cd lib
gcc -shared -fPIC -o libcalc.so libcalc.c
cd ..
post_text: "Verify the symbols are exported:"
extra_run:
run: "nm -gU lib/libcalc.{ext} | grep calc"
code_block: |
# Linux
nm -D lib/libcalc.so | grep calc
# macOS
# nm -gU lib/libcalc.dylib | grep calc
post_text: |
You should see symbols marked with `T`.
```
## Platform placeholders
These placeholders are expanded at execution time by the runner and at generation time in rendered content:
| Placeholder | Linux | macOS |
|-------------|-------|-------|
| `{ext}` | `so` | `dylib` |
| `{shared_flags}` | `-shared -fPIC` | `-dynamiclib` |
Platform placeholders work in `run`, `check_file`, `extra_run.run`, and `file.content` fields. They are **not** expanded in `code_block`, `text`, or `post_text` — those are rendered verbatim.
When a command uses platform placeholders, provide a `code_block` showing both platform variants for the markdown.
## Release tags
The `release` field lets you pin all GitHub URLs to a specific git tag. This avoids updating every URL individually when you want all `nix build 'github:logos-co/...'` commands to use the same release.
Use the `{release}` placeholder in `run` commands, `code_block`, and `file.content`:
```yaml
release: "tutorial-v2"
sections:
- title: "Set Up"
steps:
- run: "nix flake init -t github:logos-co/logos-module-builder{release}#with-external-lib"
- run: "nix build 'github:logos-co/logos-module{release}#lm' --out-link ./lm"
```
When `release` is set to `"tutorial-v2"`, `{release}` expands to `/tutorial-v2`:
```bash
nix build 'github:logos-co/logos-module/tutorial-v2#lm' --out-link ./lm
```
When `release` is empty or omitted, `{release}` expands to nothing:
```bash
nix build 'github:logos-co/logos-module#lm' --out-link ./lm
```
The `--release` CLI flag overrides the YAML field:
```bash
# Use a specific tag (overrides whatever is in the YAML)
python3 tools/tutorial_runner.py run spec.yaml --release tutorial-v3
# Generate markdown with a tag
python3 tools/tutorial_runner.py generate spec.yaml --release tutorial-v2
# Clear the tag even if the YAML sets one
python3 tools/tutorial_runner.py run spec.yaml --release ""
```
## Runner behavior
- Creates a fresh temp directory (or uses `--workdir`) — all files, builds, and commands happen there
- Walks sections and steps in order
- Executes `file`, `run`, `check_file`, `ui_test` actions
- Tracks pass/fail/skip counts
- **Stops on first failure** by default (use `--continue-on-fail` to override)
- Prints a summary report at the end
- By default the temp directory is **deleted** when the run finishes
### Working directory
The runner needs a directory to work in. There are three modes:
1. **Default (temp dir, auto-deleted):** A fresh `/tmp/tutorial-test-XXXXX/` is created and removed after the run.
2. **`--keep-workdir`:** Same temp dir, but it's kept after the run so you can inspect the results (built artifacts, installed modules, etc.).
3. **`--workdir <path>`:** Use your own directory. It is never deleted. Useful for re-running specific phases against a previous build or for debugging.
The workdir path is printed at the top of every run:
```
workdir : /tmp/tutorial-test-abc123
```
### Tutorial chaining (`requires`)
When a spec has `requires:`, the runner creates a shared parent directory and runs each prerequisite before the main tutorial. Each tutorial gets its own subdirectory named by `project_name`:
```
# Part 2 requires Part 1:
requires:
- tutorial-wrapping-c-library.test.yaml
project_name: logos-calc-ui
```
```
# Running Part 2 automatically runs Part 1 first:
python3 tools/tutorial_runner.py run tests/tutorial-qml-ui-app.test.yaml
# Resulting directory structure:
/tmp/tutorial-chain-XXXXX/
├── logos-calc-module/ # Part 1 (prerequisite)
└── logos-calc-ui/ # Part 2 (main)
```
Commands like `../logos-calc-module` in Part 2 resolve to Part 1's output. Results are cumulative — a failure in any tutorial stops the chain (unless `--continue-on-fail`).
`requires:` is resolved **transitively**. A spec only needs to declare its *direct* prerequisites; their prerequisites are pulled in automatically. For the three-part series, Part 3 can simply declare Part 2:
```
# Part 3 requires Part 2, which itself requires Part 1:
requires:
- tutorial-qml-ui-app.test.yaml
project_name: logos-calc-ui-cpp
```
```
# Running Part 3 resolves the whole graph and runs in dependency order:
python3 tools/tutorial_runner.py run tests/tutorial-cpp-ui-app.test.yaml
# Resulting directory structure (Part 1 → Part 2 → Part 3):
/tmp/tutorial-chain-XXXXX/
├── logos-calc-module/ # Part 1 (transitive prerequisite, runs first)
├── logos-calc-ui/ # Part 2 (direct prerequisite, runs second)
└── logos-calc-ui-cpp/ # Part 3 (main, runs last)
```
The graph is walked depth-first in post-order, so a prerequisite always runs before the spec that needs it. A prerequisite shared by multiple specs runs only once, and a circular `requires:` chain is reported as an error rather than looping forever.
When running standalone (no `requires:`), `project_name` is ignored and the runner behaves as before.
## Generator behavior
- Walks the same YAML structure
- Produces markdown with proper headings, code blocks, and prose
- Sections with `phase` get numbered as "Step 1:", "Step 2:", etc.
- Sections without `phase` get plain `## Title` headings
- `expect_contains` and `check_file` are not rendered (runner-only)
- `code_block` overrides the `run` value for display
- `file` content is rendered as a fenced code block with syntax highlighting
- Triple blank lines are collapsed to double
## File layout
```
repos/logos-tutorial/
├── tests/
│ └── tutorial-wrapping-c-library.test.yaml # The spec
├── tools/
│ ├── tutorial_runner.py # Runner + generator
│ └── run-tutorial # Nix wrapper script
├── tutorial-wrapping-c-library.md # Generated output
└── docs/
└── spec.md # This file
```
## Usage
```bash
# Run a tutorial (all phases)
python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --verbose
# Run specific phases
python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --phase scaffold,files,build
# Run into a specific directory (kept after run, useful for inspecting results)
python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --workdir /tmp/my-tutorial-test --verbose
# Keep the auto-generated temp directory for debugging
python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --keep-workdir --verbose
# Re-run just the logoscore phase against a previous build
python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --workdir /tmp/my-tutorial-test --phase logoscore --verbose
# Generate markdown
python3 tools/tutorial_runner.py generate tests/tutorial-wrapping-c-library.test.yaml
# Generate to a specific file
python3 tools/tutorial_runner.py generate tests/tutorial-wrapping-c-library.test.yaml -o my-output.md
# Use nix wrapper (ensures python3 + pyyaml are available)
./tools/run-tutorial run tests/tutorial-wrapping-c-library.test.yaml --verbose
```
+776
View File
@@ -0,0 +1,776 @@
name: "Tutorial Part 3: Building a C++ UI Module (Process-Isolated)"
output: tutorial-cpp-ui-app.md
project_name: logos-calc-ui-cpp
requires:
- tutorial-qml-ui-app.test.yaml
release: ""
intro: |
This is Part 3 of the Logos module tutorial series. In [Part 2](tutorial-qml-ui-app.md) you built a QML-only UI plugin. Now you'll build a **ui_qml module with a C++ backend** — the backend runs in a separate `ui-host` process while the QML view loads in the host app (basecamp / standalone).
what_you_build: |
A `calc_ui_cpp` module with:
- A `.rep` file defining the remote interface (slots)
- A C++ backend plugin that inherits from the generated `SimpleSource` base class
- A QML view that calls the backend via a typed replica using `logos.watch()`
- Process isolation: backend crashes can't bring down the host app
comparison: |
**Why C++ backend over QML-only?**
| | QML-only (Part 2) | C++ backend (Part 3) |
| ----------------- | ----------------------------------------------------------------- | --------------------------------------- |
| Compilation | None | CMake + Qt |
| Process isolation | No (QML runs in-process) | Yes (C++ in separate `ui-host` process) |
| Backend calls | `logos.callModule()` / `logos.callModuleAsync()` to other modules | `LogosModules` typed SDK in C++ |
| Type safety | Args travel as `QVariant` | C++ types preserved |
| QML ↔ backend | Direct bridge | Qt Remote Objects (typed replica) |
| `.rep` file | Not needed | Required — defines the remote interface |
prerequisites:
- "Completed [Part 1](tutorial-wrapping-c-library.md) — you have a working `calc_module` with the shared library built (`.so` on Linux, `.dylib` on macOS in `logos-calc-module/lib/`)"
- Nix with flakes enabled
sections:
# ── Architecture (prose only) ───────────────────────────────────────────────
- title: "Architecture"
text: |
```
logos-basecamp / logos-standalone-app
┌─────────────────────────────────────────────┐
│ │
│ QML View (Main.qml) │
│ readonly property var backend: │
│ logos.module("calc_ui_cpp") │
│ logos.watch(backend.add(1,2))│
│ │ │
│ │ Qt Remote Objects (socket) │
└──────────┼──────────────────────────────────┘
ui-host process (separate)
┌──────────┼──────────────────────────────────┐
│ ▼ │
│ CalcUiCppPlugin (backend) │
│ : CalcUiCppSimpleSource │
│ : CalcUiCppViewPluginBase │
│ int add(int a, int b) { │
│ return m_logos->calc_module.add(a,b); │
│ } │
│ │ │
│ │ LogosModules typed SDK │
│ ▼ │
│ calc_module (loaded in ui-host) │
└─────────────────────────────────────────────┘
```
The `.rep` file declares the interface. At build time, Qt's `repc` compiler generates:
- **`CalcUiCppSimpleSource`** — base class the backend inherits from
- **`CalcUiCppReplica`** — typed replica the QML view uses
- **`calc_ui_cpp_replica_factory`** — separate plugin that the host loads to create typed replicas
# ── Step 1: Scaffold ────────────────────────────────────────────────────────
- title: "Scaffold"
step: true
text: |
Create a new directory and initialise it from the C++ backend UI template:
`mkdir logos-calc-ui-cpp && cd logos-calc-ui-cpp`
steps:
- run: "nix flake init -t github:logos-co/logos-module-builder{release}#ui-qml-backend"
code_block: |
nix flake init -t github:logos-co/logos-module-builder{release}#ui-qml-backend
post_text: |
This creates the template. We'll customize it for our calculator.
- run: "git init && git add -A"
# ── Step 2: metadata.json ───────────────────────────────────────────────────
- title: "`metadata.json`"
step: true
text: |
Replace the template contents with your plugin's details:
steps:
- file:
path: metadata.json
language: json
content: |
{
"name": "calc_ui_cpp",
"version": "1.0.0",
"type": "ui_qml",
"category": "tools",
"description": "Calculator C++ UI — QML view with process-isolated backend for calc_module",
"main": "calc_ui_cpp_plugin",
"view": "qml/Main.qml",
"icon": "icons/calc.png",
"dependencies": ["calc_module"],
"nix": {
"packages": {
"build": [],
"runtime": []
},
"external_libraries": [],
"cmake": {
"find_packages": [],
"extra_sources": [],
"extra_include_dirs": [],
"extra_link_libraries": []
}
}
}
post_text: |
Create the icon directory and add a placeholder icon (displayed in the `logos-basecamp` sidebar when the module is loaded):
- run: "mkdir -p icons && echo 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAmElEQVR4nO3QMREAIBDAsFeEN3ziCWRkoEP2XmedfX82OkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAO0BN/SiO/PatoIAAAAASUVORK5CYII=' | base64 -d > icons/calc.png"
code_block: |
mkdir -p icons
# Copy any PNG here — or generate a 64×64 placeholder:
echo "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAmElEQVR4nO3QMREAIBDAsFeEN3ziCWRkoEP2XmedfX82OkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAO0BN/SiO/PatoIAAAAASUVORK5CYII=" | base64 -d > icons/calc.png
post_text: |
Key fields:
- `"type": "ui_qml"` — tells the builder this is a QML view module
- `"main": "calc_ui_cpp_plugin"` — the backend Qt plugin library (without extension)
- `"view": "qml/Main.qml"` — the QML entry point
- `"dependencies": ["calc_module"]` — core modules the backend calls
# ── Step 3: The .rep File ─────────────────────────────────────────────────────
- title: "The `.rep` File"
step: true
text: |
Create `src/calc_ui_cpp.rep`:
steps:
- file:
path: src/calc_ui_cpp.rep
language: rep
content: |
class CalcUiCpp
{
SLOT(int add(int a, int b))
SLOT(int multiply(int a, int b))
SLOT(int factorial(int n))
SLOT(int fibonacci(int n))
SLOT(QString libVersion())
}
post_text: |
This is the **single source of truth** for the remote interface. `repc` generates:
- `rep_calc_ui_cpp_source.h` — `CalcUiCppSimpleSource` with virtual slots the backend overrides
- `rep_calc_ui_cpp_replica.h` — `CalcUiCppReplica` with typed methods
**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.
# ── Step 4: Interface header ──────────────────────────────────────────────────
- title: "Interface header"
step: true
text: |
The scaffolded template creates a set of `ui_example` files (`src/ui_example.rep`, `src/ui_example_interface.h`, `src/ui_example_plugin.{h,cpp}`). We replace them with `calc_ui_cpp` equivalents, so remove the example sources first — leaving them around with mismatched class/IID names just invites build errors or plugin-load failures at runtime:
steps:
- run: "rm -f src/ui_example.rep src/ui_example_interface.h src/ui_example_plugin.h src/ui_example_plugin.cpp"
post_text: |
Now create `src/calc_ui_cpp_interface.h`:
- file:
path: src/calc_ui_cpp_interface.h
language: cpp
content: |
#ifndef CALC_UI_CPP_INTERFACE_H
#define CALC_UI_CPP_INTERFACE_H
#include <QObject>
#include <QString>
#include "interface.h"
class CalcUiCppInterface : public PluginInterface
{
public:
virtual ~CalcUiCppInterface() = default;
};
#define CalcUiCppInterface_iid "org.logos.CalcUiCppInterface"
Q_DECLARE_INTERFACE(CalcUiCppInterface, CalcUiCppInterface_iid)
#endif // CALC_UI_CPP_INTERFACE_H
post_text: |
Your plugin header should then include `calc_ui_cpp_interface.h` and use:
- `Q_PLUGIN_METADATA(IID CalcUiCppInterface_iid FILE "metadata.json")`
- `Q_INTERFACES(CalcUiCppInterface)`
If the interface filename or IID symbol doesn't match, you'll typically get build errors (missing header/symbol) or plugin-load failures at runtime.
# ── Step 5: CMakeLists.txt ────────────────────────────────────────────────────
- title: "`CMakeLists.txt`"
step: true
steps:
- file:
path: CMakeLists.txt
language: cmake
content: |
cmake_minimum_required(VERSION 3.14)
project(CalcUiCppPlugin LANGUAGES CXX)
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
else()
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
endif()
logos_module(
NAME calc_ui_cpp
REP_FILE src/calc_ui_cpp.rep
SOURCES
src/calc_ui_cpp_interface.h
src/calc_ui_cpp_plugin.h
src/calc_ui_cpp_plugin.cpp
)
post_text: |
`REP_FILE` tells `logos_module()` to:
1. Run `repc` to generate source/replica headers
2. Generate `CalcUiCppViewPluginBase` (typed remoting base class)
3. Build a separate `calc_ui_cpp_replica_factory` shared library
# ── Step 6: C++ Backend Plugin ────────────────────────────────────────────────
- title: "C++ Backend Plugin"
step: true
steps:
- title: "`src/calc_ui_cpp_plugin.h`"
file:
path: src/calc_ui_cpp_plugin.h
language: cpp
content: |
#ifndef CALC_UI_CPP_PLUGIN_H
#define CALC_UI_CPP_PLUGIN_H
#include <QString>
#include <QVariantList>
#include "calc_ui_cpp_interface.h"
#include "LogosViewPluginBase.h"
#include "rep_calc_ui_cpp_source.h"
class LogosAPI;
class LogosModules;
// Inherits CalcUiCppSimpleSource (generated from calc_ui_cpp.rep) so
// enableRemoting() can publish the typed source and QML replicas get
// auto-synced properties + callable slots.
class CalcUiCppPlugin : public CalcUiCppSimpleSource,
public CalcUiCppInterface,
public CalcUiCppViewPluginBase
{
Q_OBJECT
Q_PLUGIN_METADATA(IID CalcUiCppInterface_iid FILE "metadata.json")
Q_INTERFACES(CalcUiCppInterface)
public:
explicit CalcUiCppPlugin(QObject* parent = nullptr);
~CalcUiCppPlugin() override;
QString name() const override { return "calc_ui_cpp"; }
QString version() const override { return "1.0.0"; }
Q_INVOKABLE void initLogos(LogosAPI* api);
// Slots from calc_ui_cpp.rep — return values directly. The QML replica
// receives QRemoteObjectPendingReply; use logos.watch() in QML to get the value.
int add(int a, int b) override;
int multiply(int a, int b) override;
int factorial(int n) override;
int fibonacci(int n) override;
QString libVersion() override;
signals:
void eventResponse(const QString& eventName, const QVariantList& args);
private:
LogosAPI* m_logosAPI = nullptr;
LogosModules* m_logos = nullptr;
};
#endif // CALC_UI_CPP_PLUGIN_H
post_text: |
Three base classes:
- **`CalcUiCppSimpleSource`** — generated from `.rep`, provides the typed source for Qt Remote Objects
- **`CalcUiCppInterface`** — standard Logos plugin interface (`name()`, `version()`)
- **`CalcUiCppViewPluginBase`** — generated, provides `setBackend()` and `enableRemoting()`
- title: "`src/calc_ui_cpp_plugin.cpp`"
file:
path: src/calc_ui_cpp_plugin.cpp
language: cpp
content: |
#include "calc_ui_cpp_plugin.h"
#include "logos_api.h"
#include "logos_sdk.h"
CalcUiCppPlugin::CalcUiCppPlugin(QObject* parent) : CalcUiCppSimpleSource(parent) {}
CalcUiCppPlugin::~CalcUiCppPlugin() { delete m_logos; }
void CalcUiCppPlugin::initLogos(LogosAPI* api)
{
if (m_logos) return;
m_logosAPI = api;
m_logos = new LogosModules(api);
// Register this object as the Remote Objects source so the QML replica
// can see its properties and call its slots.
setBackend(this);
}
int CalcUiCppPlugin::add(int a, int b)
{
return m_logos->calc_module.add(a, b);
}
int CalcUiCppPlugin::multiply(int a, int b)
{
return m_logos->calc_module.multiply(a, b);
}
int CalcUiCppPlugin::factorial(int n)
{
return m_logos->calc_module.factorial(n);
}
int CalcUiCppPlugin::fibonacci(int n)
{
return m_logos->calc_module.fibonacci(n);
}
QString CalcUiCppPlugin::libVersion()
{
return m_logos->calc_module.libVersion();
}
post_text: |
Key points:
- Constructor calls `CalcUiCppSimpleSource(parent)` — not `QObject(parent)`
- `initLogos()` calls `setBackend(this)` to register with the Remote Objects host
- Slots return values directly — they travel back to the QML replica via Qt Remote Objects
- `m_logos->calc_module.add(a, b)` uses the generated typed SDK (type-safe, no QVariant)
# ── Step 7: QML View ──────────────────────────────────────────────────────────
- title: "QML View"
step: true
text: |
Create `src/qml/Main.qml`:
steps:
- file:
path: src/qml/Main.qml
language: qml
content: |
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
property string result: ""
property string errorText: ""
// Typed replica of the backend running in ui-host (generated from calc_ui_cpp.rep).
readonly property var backend: logos.module("calc_ui_cpp")
// logos.watch() delivers the result of a replica slot call via callbacks.
// No QtRemoteObjects import needed — the bridge handles it.
function callCalc(method, args) {
if (!backend) {
root.errorText = "Backend not available"
return
}
root.errorText = ""
root.result = "..."
logos.watch(backend[method].apply(backend, args),
function(value) { root.result = String(value) },
function(error) { root.errorText = String(error) }
)
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 24
spacing: 16
Text {
text: "Logos Calculator (C++ backend)"
font.pixelSize: 20
color: "#ffffff"
Layout.alignment: Qt.AlignHCenter
}
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: root.callCalc("add", [parseInt(inputA.text) || 0, parseInt(inputB.text) || 0])
}
Button {
text: "Multiply"
onClicked: root.callCalc("multiply", [parseInt(inputA.text) || 0, parseInt(inputB.text) || 0])
}
}
RowLayout {
spacing: 12
Layout.fillWidth: true
TextField {
id: inputN
placeholderText: "n"
Layout.preferredWidth: 80
validator: IntValidator { bottom: 0 }
}
Button {
text: "Factorial"
onClicked: root.callCalc("factorial", [parseInt(inputN.text) || 0])
}
Button {
text: "Fibonacci"
onClicked: root.callCalc("fibonacci", [parseInt(inputN.text) || 0])
}
Button {
text: "libcalc version"
onClicked: root.callCalc("libVersion", [])
}
}
Rectangle {
Layout.fillWidth: true
height: 56
color: root.errorText.length > 0 ? "#3d1a1a" : "#1a2d1a"
radius: 8
Text {
anchors.centerIn: parent
text: root.errorText.length > 0 ? root.errorText
: (root.result.length > 0 ? root.result : "Enter values and press a button")
color: root.errorText.length > 0 ? "#f85149" : "#56d364"
font.pixelSize: 15
}
}
Item { Layout.fillHeight: true }
}
}
post_text: |
Key patterns:
- `logos.module("calc_ui_cpp")` — gets the typed replica (auto-synced properties)
- `logos.watch(backend.add(1, 2), ...)` — SLOT return value as JS Promise
- The `logos` object is injected by the host at runtime — no `QtRemoteObjects` import needed
# ── Step 8: Use the Logos Design System (prose only) ──────────────────────────
- title: "Use the Logos Design System in your QML"
step: true
text: |
The QML you load above runs inside the host (`logos-basecamp` / `logos-standalone-app`), which already has `logos-design-system` on the QML import path. Use its themed components rather than rolling your own visuals — your module gets the polished look automatically as the design system evolves.
```qml
import Logos.Theme
import Logos.Controls
import Logos.Icons // optional shared icon assets
LogosButton {
text: qsTr("Add")
onClicked: root.callCalc("add", [parseInt(inputA.text) || 0,
parseInt(inputB.text) || 0])
}
LogosTextField {
id: inputA
placeholderText: qsTr("a")
}
Rectangle {
color: Theme.palette.backgroundSecondary
radius: Theme.spacing.radiusSmall
LogosText { text: qsTr("Result"); color: Theme.palette.text }
}
```
**Discover what's available** by running the storybook:
```bash
cd repos/logos-design-system && nix run
```
The sidebar splits components into:
- **Controls** — designed per Figma, production-ready (`LogosButton`, `LogosBadge`, `LogosCheckbox`, `LogosComboBox`, `LogosIconButton`, `LogosPaginator`, `LogosSearchBar`, `LogosTabBar`, `LogosTable`, `LogosText`, `LogosTextField`, `LogosToolTip`, …).
- **Controls (not designed)** — placeholders with stable APIs but unstyled visuals (`LogosDialog`, `LogosDrawer`, `LogosScrollView`, `LogosSpinner`, `LogosTextArea`, `LogosSwitch`, …). You can ship with them; they'll get the polished look applied later without you having to change your QML.
**Theme tokens** (use these instead of hex literals or magic font sizes):
- `Theme.palette.*` — `background`, `backgroundSecondary`, `surface`, `text`, `textSecondary`, `border`, `primary`, `success`, `warning`, `error`, `info`, `hover`, `pressed`, …
- `Theme.spacing.*` — `tiny`, `small`, `medium`, `large`, `xlarge`, `xxlarge`, `radiusSmall`, `radiusMedium`, `radiusLarge`
- `Theme.typography.*` — `pageTitleText` (36), `titleText` (30), `panelTitleText` (24), `subtitleText` (16), `primaryText` (14), `secondaryText` (12); `weightRegular` / `weightMedium` / `weightBold`; `publicSans`
- `Logos.Icons.LogosIcons.*` — `arrowLeft`, `arrowRight`, `refresh`, `install`, `trash`, `more`, `search`, …
**Feedback and contributions**
Feel free to report bugs, file feature requests, or contribute components / theme tokens upstream — all welcome at `logos-co/logos-design-system`. The same fix lifts every consumer, so upstreaming is the most impactful path. If you can sketch the public API you'd like to use in a feature request, it makes review and implementation much faster.
# ── Step 9: flake.nix ─────────────────────────────────────────────────────────
- title: "`flake.nix`"
step: true
text: |
The template already wires everything up. Update the description and point `calc_module` at your dependency:
steps:
- file:
path: flake.nix
language: nix
content: |
{
description = "Calculator C++ UI plugin for Logos - QML view with process-isolated backend for calc_module";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder{release}";
# Option A: point to a remote repo (for CI or when calc_module is published)
calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module";
# Option B: point to your local checkout (for local development)
# calc_module.url = "path:../logos-calc-module";
};
outputs = inputs@{ logos-module-builder, calc_module, ... }:
logos-module-builder.lib.mkLogosQmlModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
}
post_text: |
The `calc_module` input attribute name must match the dependency name in `metadata.json`. The URL can be:
- **`github:`** — fetches from a remote GitHub repo. Use for CI or when `calc_module` is published.
- **`path:`** — points to a local directory on disk (e.g., `path:../logos-calc-module`). Use during local development.
> **Important:** Whichever URL scheme you use, `calc_module` must be built with its shared library (`.so` on Linux, `.dylib` on macOS) present in `lib/`. If it's missing, the nix build will fail with linker errors. See [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library).
`mkLogosQmlModule` handles everything: compiles the C++ backend (because `main` is set), bundles the QML view, generates LGX packages, and wires up `nix run`.
# ── Step 10: Build and Run ────────────────────────────────────────────────────
- title: "Build and Run"
step: true
text: |
First, make sure your local `calc_module` is built and its shared library is present in `lib/` (see [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library)):
steps:
- title: "Ensure `calc_module` is built"
run: "ls ../logos-calc-module/lib/libcalc.{ext}"
code_block: |
ls ../logos-calc-module/lib/libcalc.so # Linux
ls ../logos-calc-module/lib/libcalc.dylib # macOS
post_text: |
If the file is missing, build it first (as covered in [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library)):
extra_run:
run: "cd ../logos-calc-module/lib && gcc {shared_flags} -o libcalc.{ext} libcalc.c && cd ../../logos-calc-ui-cpp"
code_block: |
cd ../logos-calc-module/lib
gcc -shared -fPIC -o libcalc.so libcalc.c # Linux
# gcc -shared -fPIC -o libcalc.dylib libcalc.c # macOS
cd ../../logos-calc-ui-cpp
- title: "Lock and build"
text: |
Stage your files and lock the flake. Then build and run — choose the approach that matches your `flake.nix` setup.
run: "git add -A"
- run: "nix flake update"
- run: "git add flake.lock"
post_text: |
```bash
# If flake.nix uses path:../logos-calc-module — just run directly:
nix run
# If flake.nix uses github: — override to use your local checkout:
nix run --override-input calc_module path:../logos-calc-module
# Or from the workspace:
./scripts/ws run logos-calc-ui-cpp --local logos-calc-ui-cpp logos-calc-module
```
- title: "Launch and verify the UI"
text: |
Launch the app and confirm the view loads with all of its controls. The backend runs in a separate `ui-host` process; clicking **Add** sends the call over Qt Remote Objects and the result comes back through `logos.watch()`.
ui_test:
launch: "nix run . --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 window opens with title"
action: wait_for
texts: ["Logos Calculator (C++ backend)"]
timeout: 30000
- name: "Operation buttons visible"
action: wait_for
texts: ["Add", "Multiply", "Factorial", "Fibonacci"]
timeout: 5000
- name: "Enter operands"
action: set_text
find_by: "placeholderText"
find_value: "a"
value: "3"
- name: "Set second operand"
action: set_text
find_by: "placeholderText"
find_value: "b"
value: "5"
- name: "Click Add"
action: click
target: "Add"
- name: "Result of 3 + 5 shows 8"
action: wait_for
texts: ["8"]
timeout: 10000
post_text: |
The result `8` comes from `calc_module.add(3, 5)` executed in the C++ backend — proof the full path (QML replica → Qt Remote Objects → ui-host backend → typed SDK → `calc_module`) works end to end.
# ── Step 11: Live reloading (prose only) ──────────────────────────────────────
- title: "Live reloading QML with `DEV_QML_PATH`"
step: true
text: |
For QML iteration, point `DEV_QML_PATH` at the directory that contains your view entry's **basename** (from `metadata.json` `"view"`). This tutorial sets `"view": "qml/Main.qml"`, so the directory must contain `Main.qml` (here: `src/qml/`):
```bash
DEV_QML_PATH=$PWD/src/qml nix run .
```
When `DEV_QML_PATH` is set, `logos-standalone-app` loads QML from your source tree at runtime instead of the installed copy — so edits to `Main.qml` (and any QML under that tree) are picked up on the next relaunch without you having to re-sync files.
**Important — what this does *not* skip.** `nix run` always re-evaluates the flake and rehashes the source tree before launching. By default `src = ./.` includes every tracked file, including `*.qml` — so:
- **Any source change, including QML edits, rebuilds the plugin** before the app starts. `DEV_QML_PATH` only kicks in *after* the build is done; it doesn't shortcut the rebuild itself.
- **C++ / `.rep` / `metadata.json` / CMake changes** rebuild as normal.
- The flake-evaluation overhead on each `nix run` is fixed and unavoidable while invoking through nix.
For the absolute fastest loop (no nix involvement after the first build), do the build once and run the resulting binary directly:
```bash
# Build once — populates result/ in the nix store
nix build .
# Subsequent runs: invoke the bundled standalone wrapper directly,
# skipping nix entirely. DEV_QML_PATH still redirects QML loading.
DEV_QML_PATH=$PWD/src/qml ./result/bin/run-logos-standalone-ui
```
(Adjust the binary name to whatever `ls result/bin/` shows on your build.)
> **Naming:** Only `DEV_QML_PATH` is honored by `logos-standalone-app`. See `repos/logos-standalone-app/README.md`.
> This does not work with `logos-basecamp` — Basecamp loads QML plugins from its own install tree, so source edits are not picked up until you rebuild and reinstall the `.lgx`.
# ── Step 12: How the Pieces Connect (prose only) ──────────────────────────────
- title: "How the Pieces Connect"
step: true
text: |
1. `nix build` → compiles the C++ plugin + replica factory, bundles QML view
2. `nix run` → launches `logos-standalone-app` which:
- Loads `calc_module` (dependency)
- Spawns a `ui-host` child process with `calc_ui_cpp_plugin.so`
- `ui-host` calls `initLogos()` → `setBackend(this)` → `enableRemoting(host)`
- Backend is now accessible over a local socket
3. Host app loads `calc_ui_cpp_replica_factory.dylib` → creates a typed replica
4. QML gets the replica via `logos.module("calc_ui_cpp")`
5. `backend.add(1, 2)` → Qt Remote Objects sends call to ui-host → backend runs → returns result
# ── Step 13: UI Integration Tests ─────────────────────────────────────────────
- title: "UI Integration Tests"
step: true
text: |
Add automated UI tests using the [logos-qt-mcp](https://github.com/logos-co/logos-qt-mcp) test framework. Just create `.mjs` files in `tests/` and `logos-module-builder` auto-wires `nix build .#integration-test`.
Tests connect to the QML inspector inside `logos-standalone-app` and can find elements, click buttons, verify text, and take screenshots.
steps:
- title: "Create a test file"
text: |
Create `tests/ui-tests.mjs`:
file:
path: tests/ui-tests.mjs
language: javascript
content: |
import { resolve } from "node:path";
// CI sets LOGOS_QT_MCP automatically; for interactive use: nix build .#test-framework -o result-mcp
const root =
process.env.LOGOS_QT_MCP ||
new URL("../result-mcp", import.meta.url).pathname;
const { test, run } = await import(
resolve(root, "test-framework/framework.mjs")
);
test("calc_ui_cpp: loads and shows title", async (app) => {
await app.waitFor(
async () => {
await app.expectTexts(["Logos Calculator (C++ backend)"]);
},
{ timeout: 15000, interval: 500, description: "UI to load" },
);
});
test("calc_ui_cpp: operation buttons visible", async (app) => {
await app.expectTexts(["Add", "Multiply", "Factorial", "Fibonacci"]);
});
run();
- title: "Run the tests"
run: "git add tests/"
- run: "nix build .#integration-test -L --override-input calc_module path:../logos-calc-module"
code_block: |
# Hermetic CI test
nix build .#integration-test -L
post_text: |
The `integration-test` output launches `logos-standalone-app` with `QT_QPA_PLATFORM=offscreen` (no display needed), connects to the QML inspector, and runs all `.mjs` files in `tests/`.
To run tests interactively (against an already-running app):
```bash
nix build .#test-framework -o result-mcp
nix run . # app with inspector on :3768
node tests/ui-tests.mjs # in another terminal
```
# ── Comparison: .rep Interface Patterns (prose only) ──────────────────────────
- title: "Comparison: .rep Interface Patterns"
text: |
| Pattern | .rep declaration | Backend C++ | 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) | `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")` |
# ── Next Steps (prose only) ───────────────────────────────────────────────────
- title: "Next Steps"
text: |
- Add more `.rep` properties/signals for richer UI state
- Use `logos.model()` for list views backed by `QAbstractItemModel`
- Package as `.lgx` for distribution: `nix build .#lgx`
- **Use the Logos Design System** in your QML — see [Step 8](#step-8-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
+883
View File
@@ -0,0 +1,883 @@
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., `calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module"`. The URL can point to any repo, but 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 via `eventResponse`, and the QML receives it through the `logos.onModuleEvent()` subscription set up in `Component.onCompleted`.
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}";
# Option A: point to a remote repo (for CI or when calc_module is published)
calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module";
# Option B: point to your local checkout (for local development)
# calc_module.url = "path:../logos-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 `calc_module.url` can be either:
- **`github:`** — fetches from a remote GitHub repo. Use this for CI or when `calc_module` has been published.
- **`path:`** — points to a local directory on disk. Use this during development when both repos live side by side (e.g., `path:../logos-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.
> **Tip:** Even if `flake.nix` uses a `github:` URL, you can override it at build time with `--override-input calc_module path:../logos-calc-module` to use your local checkout without editing `flake.nix`. This is covered in [Step 5.2](#52-full-functionality-with-modules).
# ── 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"
- 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
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: |
If your `flake.nix` points to a `github:` URL, you can override it at build time to use your local checkout:
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: Set `path:` in `flake.nix` (persistent local development)"
text: |
If you're iterating on both repos side by side, you can point the flake input directly to your local `calc_module` checkout. In `flake.nix`, change:
```nix
# From remote:
calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module";
# To local:
calc_module.url = "path:../logos-calc-module";
```
Then run normally without overrides:
```bash
nix flake update # re-lock with the local path
git add flake.lock
nix run .
```
This is convenient when you always want to build against the local copy. Switch back to `github:` when you're ready to pin to a published version.
- title: "Option C: Pin to the remote repo"
text: |
If `calc_module` has been pushed to the remote repository (with the `.so`/`.dylib` committed in `lib/`), the `github:` URL in `flake.nix` already points to it. 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 build '.#lgx' --out-link result-lgx && nix build '.#lgx-portable' --out-link result-lgx-portable"
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 and run logos-basecamp"
text: |
Build logos-basecamp, launch it once to preinstall its bundled modules, then install your modules.
> **Note:** `logos-basecamp` does not accept `--modules-dir` or `--ui-plugins-dir` CLI flags. It manages its own data directory and preinstalls bundled modules (main_ui, package_manager, etc.) on first launch.
run: "nix build 'github:logos-co/logos-basecamp{release}' -o basecamp-result"
post_text: |
```bash
# Launch once to preinstall bundled modules, then close it
./basecamp-result/bin/logos-basecamp
```
Basecamp creates its data directory on first launch. To find where it is, check the log output for `plugins directory` or look for the directory that contains `modules/` and `plugins/` subdirectories:
```bash
# macOS (typical path, may vary):
ls ~/Library/Application\ Support/Logos/
# Linux (typical path, may vary):
ls ~/.local/share/Logos/
```
The dev build directory is named `LogosBasecampDev` (portable builds use `LogosBasecamp`).
- title: "Install modules with lgpm"
text: |
Install your modules using `lgpm`. First, set `BASECAMP_DIR` to your platform's path:
```bash
# macOS:
BASECAMP_DIR="$HOME/Library/Application Support/Logos/LogosBasecampDev"
# Linux:
BASECAMP_DIR="$HOME/.local/share/Logos/LogosBasecampDev"
```
run: "nix build 'github:logos-co/logos-package-manager{release}#cli' --out-link ./pm"
post_text: |
```bash
# Install core module
./pm/bin/lgpm --modules-dir "$BASECAMP_DIR/modules" \
install --file ../logos-calc-module/result-lgx/*.lgx
# Install UI plugin
./pm/bin/lgpm --ui-plugins-dir "$BASECAMP_DIR/plugins" \
install --file result-lgx/*.lgx
# Launch basecamp -- your modules appear alongside the built-in ones
./basecamp-result/bin/logos-basecamp
```
- 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/logos-basecamp
```
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/logos-basecamp
```
> **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`
The "Calculator UI" tab appears in the sidebar. 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 build .#integration-test -L --override-input calc_module path:../logos-calc-module"
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 `int`, and numeric strings will be converted to their numeric types.
This means you can define methods with their natural parameter types (`int`, `bool`, `double`, etc.) and calls from QML will work without manual conversion:
```cpp
// This works — the runtime coerces arguments automatically
Q_INVOKABLE int add(int a, int b) { return a + 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/logos-basecamp
```
### 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/logos-basecamp
```
Then reinstall your custom modules.
# ── Recap (prose only) ─────────────────────────────────────────────────────
- title: "Recap"
text: |
| | Core Module (Part 1) | QML UI Plugin (Part 2) |
| ------------------- | ----------------------------------------------- | ----------------------------- |
| Language | C++ | QML / JavaScript |
| Files | `.cpp`, `.h`, `CMakeLists.txt`, `metadata.json` | `Main.qml`, `metadata.json` |
| Compilation | Yes (CMake → `.so`) | No (file copy) |
| `metadata.type` | `"core"` | `"ui_qml"` |
| Test command | `logoscore -m ./result/lib -l calc_module` | `nix run .` |
| Calls other modules | Via `LogosAPI*` (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
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Wrapper that ensures python3 + pyyaml are available via nix-shell.
# Usage: run-tutorial run <spec.yaml> [OPTIONS]
# run-tutorial generate <spec.yaml> [-o output.md]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# If python3 + yaml are already available, run directly
if python3 -c "import yaml" 2>/dev/null; then
exec python3 "$SCRIPT_DIR/tutorial_runner.py" "$@"
fi
# Otherwise, use nix-shell to provide them
exec nix-shell -p python3 python3Packages.pyyaml --run \
"python3 \"$SCRIPT_DIR/tutorial_runner.py\" $*"
File diff suppressed because it is too large Load Diff
+181 -80
View File
@@ -4,7 +4,7 @@ This is Part 3 of the Logos module tutorial series. In [Part 2](tutorial-qml-ui-
**What you'll build:** A `calc_ui_cpp` module with:
- A `.rep` file defining the remote interface (slots + properties)
- A `.rep` file defining the remote interface (slots)
- A C++ backend plugin that inherits from the generated `SimpleSource` base class
- A QML view that calls the backend via a typed replica using `logos.watch()`
- Process isolation: backend crashes can't bring down the host app
@@ -20,7 +20,7 @@ This is Part 3 of the Logos module tutorial series. In [Part 2](tutorial-qml-ui-
| QML ↔ backend | Direct bridge | Qt Remote Objects (typed replica) |
| `.rep` file | Not needed | Required — defines the remote interface |
**Prerequisites:**
## 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
@@ -63,21 +63,27 @@ The `.rep` file declares the interface. At build time, Qt's `repc` compiler gene
- **`CalcUiCppReplica`** — typed replica the QML view uses
- **`calc_ui_cpp_replica_factory`** — separate plugin that the host loads to create typed replicas
---
## Step 1: Scaffold
Create a new directory and initialise it from the C++ backend UI template:
`mkdir logos-calc-ui-cpp && cd logos-calc-ui-cpp`
```bash
mkdir logos-calc-ui-cpp && cd logos-calc-ui-cpp
nix flake init -t github:logos-co/logos-module-builder#ui-qml-backend
git init && git add -A
```
This creates the template. We'll customize it for our calculator.
```bash
git init && git add -A
```
---
## Step 2: metadata.json
## Step 2: `metadata.json`
Replace the template contents with your plugin's details:
```json
{
@@ -85,20 +91,36 @@ This creates the template. We'll customize it for our calculator.
"version": "1.0.0",
"type": "ui_qml",
"category": "tools",
"description": "Calculator C++ UI — QML view with process-isolated backend",
"description": "Calculator C++ UI — QML view with process-isolated backend for calc_module",
"main": "calc_ui_cpp_plugin",
"view": "qml/Main.qml",
"icon": "icons/calc.png",
"dependencies": ["calc_module"],
"nix": {
"packages": { "build": [], "runtime": [] },
"packages": {
"build": [],
"runtime": []
},
"external_libraries": [],
"cmake": { "find_packages": [], "extra_sources": [] }
"cmake": {
"find_packages": [],
"extra_sources": [],
"extra_include_dirs": [],
"extra_link_libraries": []
}
}
}
```
Create the icon directory and add a placeholder icon (displayed in the `logos-basecamp` sidebar when the module is loaded):
```bash
mkdir -p icons
# Copy any PNG here — or generate a 64×64 placeholder:
echo "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAmElEQVR4nO3QMREAIBDAsFeEN3ziCWRkoEP2XmedfX82OkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAO0BN/SiO/PatoIAAAAASUVORK5CYII=" | base64 -d > icons/calc.png
```
Key fields:
- `"type": "ui_qml"` — tells the builder this is a QML view module
@@ -115,8 +137,6 @@ Create `src/calc_ui_cpp.rep`:
```rep
class CalcUiCpp
{
PROP(QString status READWRITE)
SLOT(int add(int a, int b))
SLOT(int multiply(int a, int b))
SLOT(int factorial(int n))
@@ -128,25 +148,25 @@ class CalcUiCpp
This is the **single source of truth** for the remote interface. `repc` generates:
- `rep_calc_ui_cpp_source.h``CalcUiCppSimpleSource` with virtual slots the backend overrides
- `rep_calc_ui_cpp_replica.h``CalcUiCppReplica` with typed methods and auto-synced properties
- `rep_calc_ui_cpp_replica.h``CalcUiCppReplica` with typed methods
**PROP** values auto-sync from backend to QML replica. **SLOT** return values are delivered as `QRemoteObjectPendingReply` — use `logos.watch()` in QML to get them as JS Promises.
**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.
---
## Step 3.1: Update the interface header
## Step 4: Interface header
The scaffolded template may create an interface file like `src/ui_example_interface.h`. Rename it to match this tutorial and make sure the class/IID names are updated, or the plugin metadata wiring will break.
The scaffolded template creates a set of `ui_example` files (`src/ui_example.rep`, `src/ui_example_interface.h`, `src/ui_example_plugin.{h,cpp}`). We replace them with `calc_ui_cpp` equivalents, so remove the example sources first — leaving them around with mismatched class/IID names just invites build errors or plugin-load failures at runtime:
```bash
# If your scaffold created ui_example files, rename the interface header:
mv src/ui_example_interface.h src/calc_ui_cpp_interface.h
rm -f src/ui_example.rep src/ui_example_interface.h src/ui_example_plugin.h src/ui_example_plugin.cpp
```
Set `src/calc_ui_cpp_interface.h` to:
Now create `src/calc_ui_cpp_interface.h`:
```cpp
#pragma once
#ifndef CALC_UI_CPP_INTERFACE_H
#define CALC_UI_CPP_INTERFACE_H
#include <QObject>
#include <QString>
@@ -160,9 +180,12 @@ public:
#define CalcUiCppInterface_iid "org.logos.CalcUiCppInterface"
Q_DECLARE_INTERFACE(CalcUiCppInterface, CalcUiCppInterface_iid)
#endif // CALC_UI_CPP_INTERFACE_H
```
Your plugin header should then include `calc_ui_cpp_interface.h` and use:
- `Q_PLUGIN_METADATA(IID CalcUiCppInterface_iid FILE "metadata.json")`
- `Q_INTERFACES(CalcUiCppInterface)`
@@ -170,7 +193,7 @@ If the interface filename or IID symbol doesn't match, you'll typically get buil
---
## Step 4: CMakeLists.txt
## Step 5: `CMakeLists.txt`
```cmake
cmake_minimum_required(VERSION 3.14)
@@ -195,17 +218,18 @@ logos_module(
`REP_FILE` tells `logos_module()` to:
1. Run `repc` to generate source/replica headers
2. Generate `LogosViewPluginBase` (typed remoting base class)
2. Generate `CalcUiCppViewPluginBase` (typed remoting base class)
3. Build a separate `calc_ui_cpp_replica_factory` shared library
---
## Step 5: C++ Backend Plugin
## Step 6: C++ Backend Plugin
### `src/calc_ui_cpp_plugin.h`
### 6.1 `src/calc_ui_cpp_plugin.h`
```cpp
#pragma once
#ifndef CALC_UI_CPP_PLUGIN_H
#define CALC_UI_CPP_PLUGIN_H
#include <QString>
#include <QVariantList>
@@ -216,6 +240,9 @@ logos_module(
class LogosAPI;
class LogosModules;
// Inherits CalcUiCppSimpleSource (generated from calc_ui_cpp.rep) so
// enableRemoting() can publish the typed source and QML replicas get
// auto-synced properties + callable slots.
class CalcUiCppPlugin : public CalcUiCppSimpleSource,
public CalcUiCppInterface,
public CalcUiCppViewPluginBase
@@ -233,7 +260,8 @@ public:
Q_INVOKABLE void initLogos(LogosAPI* api);
// Slots from .rep — override the generated virtuals
// Slots from calc_ui_cpp.rep — return values directly. The QML replica
// receives QRemoteObjectPendingReply; use logos.watch() in QML to get the value.
int add(int a, int b) override;
int multiply(int a, int b) override;
int factorial(int n) override;
@@ -247,6 +275,8 @@ private:
LogosAPI* m_logosAPI = nullptr;
LogosModules* m_logos = nullptr;
};
#endif // CALC_UI_CPP_PLUGIN_H
```
Three base classes:
@@ -255,23 +285,23 @@ Three base classes:
- **`CalcUiCppInterface`** — standard Logos plugin interface (`name()`, `version()`)
- **`CalcUiCppViewPluginBase`** — generated, provides `setBackend()` and `enableRemoting()`
### `src/calc_ui_cpp_plugin.cpp`
### 6.2 `src/calc_ui_cpp_plugin.cpp`
```cpp
#include "calc_ui_cpp_plugin.h"
#include "logos_api.h"
#include "logos_sdk.h"
CalcUiCppPlugin::CalcUiCppPlugin(QObject* parent)
: CalcUiCppSimpleSource(parent) {}
CalcUiCppPlugin::CalcUiCppPlugin(QObject* parent) : CalcUiCppSimpleSource(parent) {}
CalcUiCppPlugin::~CalcUiCppPlugin() { delete m_logos; }
void CalcUiCppPlugin::initLogos(LogosAPI* api)
{
if (m_logos) return;
m_logosAPI = api;
m_logos = new LogosModules(api);
// Register this object as the Remote Objects source
// Register this object as the Remote Objects source so the QML replica
// can see its properties and call its slots.
setBackend(this);
}
@@ -310,7 +340,7 @@ Key points:
---
## Step 6: QML View
## Step 7: QML View
Create `src/qml/Main.qml`:
@@ -319,19 +349,17 @@ import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
property string result: ""
property string errorText: ""
// Typed replica of the backend running in ui-host
// Typed replica of the backend running in ui-host (generated from calc_ui_cpp.rep).
readonly property var backend: logos.module("calc_ui_cpp")
// "status" property from the .rep — auto-synced via Qt Remote Objects
readonly property string status: backend ? backend.status : ""
// logos.watch() delivers the result of a replica slot call via callbacks.
// No QtRemoteObjects import needed — the bridge handles it.
function callCalc(method, args) {
if (!backend) {
root.errorText = "Backend not available"
@@ -339,7 +367,6 @@ Item {
}
root.errorText = ""
root.result = "..."
// logos.watch() wraps the pending reply in a JS Promise
logos.watch(backend[method].apply(backend, args),
function(value) { root.result = String(value) },
function(error) { root.errorText = String(error) }
@@ -352,52 +379,84 @@ Item {
spacing: 16
Text {
text: "Calculator (C++ backend)"
text: "Logos Calculator (C++ backend)"
font.pixelSize: 20
color: "#ffffff"
Layout.alignment: Qt.AlignHCenter
}
RowLayout {
spacing: 12
Layout.fillWidth: true
TextField {
id: inputA; placeholderText: "a"
id: inputA
placeholderText: "a"
Layout.preferredWidth: 80
validator: IntValidator {}
}
TextField {
id: inputB; placeholderText: "b"
id: inputB
placeholderText: "b"
Layout.preferredWidth: 80
validator: IntValidator {}
}
Button {
text: "Add"
onClicked: root.callCalc("add", [parseInt(inputA.text) || 0,
parseInt(inputB.text) || 0])
onClicked: root.callCalc("add", [parseInt(inputA.text) || 0, parseInt(inputB.text) || 0])
}
Button {
text: "Multiply"
onClicked: root.callCalc("multiply", [parseInt(inputA.text) || 0,
parseInt(inputB.text) || 0])
onClicked: root.callCalc("multiply", [parseInt(inputA.text) || 0, parseInt(inputB.text) || 0])
}
}
RowLayout {
spacing: 12
Layout.fillWidth: true
TextField {
id: inputN
placeholderText: "n"
Layout.preferredWidth: 80
validator: IntValidator { bottom: 0 }
}
Button {
text: "Factorial"
onClicked: root.callCalc("factorial", [parseInt(inputN.text) || 0])
}
Button {
text: "Fibonacci"
onClicked: root.callCalc("fibonacci", [parseInt(inputN.text) || 0])
}
Button {
text: "libcalc version"
onClicked: root.callCalc("libVersion", [])
}
}
Rectangle {
Layout.fillWidth: true; height: 56
color: root.errorText ? "#3d1a1a" : "#1a2d1a"
Layout.fillWidth: true
height: 56
color: root.errorText.length > 0 ? "#3d1a1a" : "#1a2d1a"
radius: 8
Text {
anchors.centerIn: parent
text: root.errorText || root.result || "Press a button"
color: root.errorText ? "#f85149" : "#56d364"
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
}
}
Text {
text: "Backend status: " + root.status
color: "#8b949e"; font.pixelSize: 13
}
Item { Layout.fillHeight: true }
}
}
```
@@ -405,13 +464,12 @@ Item {
Key patterns:
- `logos.module("calc_ui_cpp")` — gets the typed replica (auto-synced properties)
- `backend.status` — PROP from `.rep`, updates automatically
- `logos.watch(backend.add(1, 2), ...)` — SLOT return value as JS Promise
- ``— required for`logos.watch()`
- The `logos` object is injected by the host at runtime — no `QtRemoteObjects` import needed
---
## Step 6.5: Use the Logos Design System in your QML
## Step 8: Use the Logos Design System in your QML
The QML you load above runs inside the host (`logos-basecamp` / `logos-standalone-app`), which already has `logos-design-system` on the QML import path. Use its themed components rather than rolling your own visuals — your module gets the polished look automatically as the design system evolves.
@@ -462,11 +520,13 @@ Feel free to report bugs, file feature requests, or contribute components / them
---
## Step 7: flake.nix
## Step 9: `flake.nix`
The template already wires everything up. Update the description and point `calc_module` at your dependency:
```nix
{
description = "Calculator C++ UI plugin QML view with process-isolated backend";
description = "Calculator C++ UI plugin for Logos - QML view with process-isolated backend for calc_module";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
@@ -478,7 +538,7 @@ Feel free to report bugs, file feature requests, or contribute components / them
# calc_module.url = "path:../logos-calc-module";
};
outputs = inputs@{ logos-module-builder, ... }:
outputs = inputs@{ logos-module-builder, calc_module, ... }:
logos-module-builder.lib.mkLogosQmlModule {
src = ./.;
configFile = ./metadata.json;
@@ -498,20 +558,43 @@ The `calc_module` input attribute name must match the dependency name in `metada
---
## Step 8: Build and Run
## Step 10: Build and Run
First, make sure your local `calc_module` is built and its `.so`/`.dylib` is present in `lib/` (see [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library)):
First, make sure your local `calc_module` is built and its shared library is present in `lib/` (see [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library)):
### 10.1 Ensure `calc_module` is built
```bash
ls ../logos-calc-module/lib/libcalc.so # Linux
ls ../logos-calc-module/lib/libcalc.dylib # macOS
```
Then build and run. Choose the approach that matches your `flake.nix` setup:
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)):
```bash
cd ../logos-calc-module/lib
gcc -shared -fPIC -o libcalc.so libcalc.c # Linux
# gcc -shared -fPIC -o libcalc.dylib libcalc.c # macOS
cd ../../logos-calc-ui-cpp
```
### 10.2 Lock and build
Stage your files and lock the flake. Then build and run — choose the approach that matches your `flake.nix` setup.
```bash
git add -A
```
```bash
nix flake update
```
```bash
git add flake.lock
```
```bash
# If flake.nix uses path:../logos-calc-module — just run directly:
nix run
@@ -522,12 +605,24 @@ nix run --override-input calc_module path:../logos-calc-module
./scripts/ws run logos-calc-ui-cpp --local logos-calc-ui-cpp logos-calc-module
```
### Live reloading QML with `DEV_QML_PATH`
### 10.3 Launch and verify the UI
For QML iteration, point `DEV_QML_PATH` at the directory that contains your view entry's **basename** (from `metadata.json` `"view"`). This tutorial sets `"view": "qml/Main.qml"`, so the directory must contain `Main.qml` (here: `qml/` at the repo root):
Launch the app and confirm the view loads with all of its controls. The backend runs in a separate `ui-host` process; clicking **Add** sends the call over Qt Remote Objects and the result comes back through `logos.watch()`.
```bash
DEV_QML_PATH=$PWD/qml nix run .
nix run . --override-input calc_module path:../logos-calc-module
```
The result `8` comes from `calc_module.add(3, 5)` executed in the C++ backend — proof the full path (QML replica → Qt Remote Objects → ui-host backend → typed SDK → `calc_module`) works end to end.
---
## Step 11: Live reloading QML with `DEV_QML_PATH`
For QML iteration, point `DEV_QML_PATH` at the directory that contains your view entry's **basename** (from `metadata.json` `"view"`). This tutorial sets `"view": "qml/Main.qml"`, so the directory must contain `Main.qml` (here: `src/qml/`):
```bash
DEV_QML_PATH=$PWD/src/qml nix run .
```
When `DEV_QML_PATH` is set, `logos-standalone-app` loads QML from your source tree at runtime instead of the installed copy — so edits to `Main.qml` (and any QML under that tree) are picked up on the next relaunch without you having to re-sync files.
@@ -546,7 +641,7 @@ nix build .
# Subsequent runs: invoke the bundled standalone wrapper directly,
# skipping nix entirely. DEV_QML_PATH still redirects QML loading.
DEV_QML_PATH=$PWD/qml ./result/bin/run-logos-standalone-ui
DEV_QML_PATH=$PWD/src/qml ./result/bin/run-logos-standalone-ui
```
(Adjust the binary name to whatever `ls result/bin/` shows on your build.)
@@ -557,7 +652,7 @@ DEV_QML_PATH=$PWD/qml ./result/bin/run-logos-standalone-ui
---
## Step 9: How the Pieces Connect
## Step 12: How the Pieces Connect
1. `nix build` → compiles the C++ plugin + replica factory, bundles QML view
2. `nix run` → launches `logos-standalone-app` which:
@@ -568,14 +663,17 @@ DEV_QML_PATH=$PWD/qml ./result/bin/run-logos-standalone-ui
3. Host app loads `calc_ui_cpp_replica_factory.dylib` → creates a typed replica
4. QML gets the replica via `logos.module("calc_ui_cpp")`
5. `backend.add(1, 2)` → Qt Remote Objects sends call to ui-host → backend runs → returns result
6. `backend.status` auto-syncs whenever the backend calls `setStatus(...)`
---
## Step 10: UI Integration Tests (Optional)
## Step 13: UI Integration Tests
Add automated UI tests using the [logos-qt-mcp](https://github.com/logos-co/logos-qt-mcp) test framework. Just create `.mjs` files in `tests/` and `logos-module-builder` auto-wires `nix build .#integration-test`.
Tests connect to the QML inspector inside `logos-standalone-app` and can find elements, click buttons, verify text, and take screenshots.
### 13.1 Create a test file
Create `tests/ui-tests.mjs`:
```javascript
@@ -592,30 +690,35 @@ const { test, run } = await import(
test("calc_ui_cpp: loads and shows title", async (app) => {
await app.waitFor(
async () => {
await app.expectTexts(["UI Example (C++ backend)"]);
await app.expectTexts(["Logos Calculator (C++ backend)"]);
},
{ timeout: 15000, interval: 500, description: "UI to load" },
);
});
test("calc_ui_cpp: shows connection status", async (app) => {
await app.expectTexts(["Connecting to backend..."]);
});
test("calc_ui_cpp: add button visible", async (app) => {
await app.expectTexts(["Add"]);
test("calc_ui_cpp: operation buttons visible", async (app) => {
await app.expectTexts(["Add", "Multiply", "Factorial", "Fibonacci"]);
});
run();
```
### 13.2 Run the tests
```bash
git add tests/
```
```bash
# Hermetic CI test
nix build .#integration-test -L
```
# Interactive
The `integration-test` output launches `logos-standalone-app` with `QT_QPA_PLATFORM=offscreen` (no display needed), connects to the QML inspector, and runs all `.mjs` files in `tests/`.
To run tests interactively (against an already-running app):
```bash
nix build .#test-framework -o result-mcp
nix run . # app with inspector on :3768
node tests/ui-tests.mjs # in another terminal
@@ -632,12 +735,10 @@ node tests/ui-tests.mjs # in another terminal
| **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")` |
---
## Next Steps
- Add more `.rep` properties/signals for richer UI state
- Use `logos.model()` for list views backed by `QAbstractItemModel`
- Package as `.lgx` for distribution: `nix build .#lgx`
- **Use the Logos Design System** in your QML — see [Step 6.5](#step-65-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` (bugs on designed components, *feature* type for new components / variants / theme tokens).
- **Use the Logos Design System** in your QML — see [Step 8](#step-8-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
+66 -41
View File
@@ -11,7 +11,7 @@ This is Part 2 of the Logos module tutorial series. In [Part 1](tutorial-wrappin
- The project structure and metadata for a QML plugin
- How to package and install your UI into `logos-basecamp`
**Prerequisites:**
## 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)
@@ -40,20 +40,26 @@ Key points:
- **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
Use the QML module template from `logos-module-builder`:
Create a new directory and initialise it from the QML module template:
`mkdir logos-calc-ui && cd logos-calc-ui`
```bash
mkdir logos-calc-ui && cd logos-calc-ui
nix flake init -t github:logos-co/logos-module-builder#ui-qml
git init && git add -A
```
> **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.
```bash
git init
```
```bash
git add -A
```
This gives you:
```
@@ -361,18 +367,29 @@ The `calc_module.url` can be either:
```bash
git add -A
nix flake update # regenerate flake.lock to match the pinned inputs in flake.nix
```
```bash
nix flake update
```
```bash
git add flake.lock
```
```bash
nix run .
```
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.
### 5.2 Full functionality (with modules)
---
## Step 6: Full functionality (with modules)
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.
#### Ensure `calc_module` is built
### 6.1 Ensure `calc_module` is built
Go back to your `logos-calc-module` directory and verify the shared library exists:
@@ -401,7 +418,7 @@ cd ../logos-calc-ui
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.
#### Option A: Use `--override-input` (quick, no flake.nix edits)
### 6.2 Option A: Use `--override-input` (quick, no flake.nix edits)
If your `flake.nix` points to a `github:` URL, you can override it at build time to use your local checkout:
@@ -411,7 +428,7 @@ nix run . --override-input calc_module path:../logos-calc-module
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.
#### Option B: Set `path:` in `flake.nix` (persistent local development)
### 6.3 Option B: Set `path:` in `flake.nix` (persistent local development)
If you're iterating on both repos side by side, you can point the flake input directly to your local `calc_module` checkout. In `flake.nix`, change:
@@ -432,7 +449,7 @@ nix run .
This is convenient when you always want to build against the local copy. Switch back to `github:` when you're ready to pin to a published version.
#### Option C: Pin to the remote repo
### 6.4 Option C: Pin to the remote repo
If `calc_module` has been pushed to the remote repository (with the `.so`/`.dylib` committed in `lib/`), the `github:` URL in `flake.nix` already points to it. A plain `nix run .` fetches and builds `calc_module` from the remote:
@@ -446,7 +463,7 @@ Whichever option you choose, clicking **Add**, **Multiply**, **Factorial**, or *
---
## Step 6: Using the Logos Design System
## Step 7: Using the Logos Design System
`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.
@@ -528,9 +545,9 @@ Feel free to report bugs, file feature requests, or contribute components / them
---
## Step 7: Load in `logos-basecamp`
## Step 8: Load in `logos-basecamp`
### 7.1 Bundle as LGX packages
### 8.1 Bundle as LGX packages
Create `.lgx` packages for both dev and portable variants. Use `--out-link` to avoid overwriting the `result` symlink:
@@ -548,16 +565,21 @@ nix build '.#lgx-portable' --out-link result-lgx-portable
> 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).
### 7.2 Build and run logos-basecamp
```bash
nix build '.#lgx' --out-link result-lgx && nix build '.#lgx-portable' --out-link result-lgx-portable
```
### 8.2 Build and run logos-basecamp
Build logos-basecamp, launch it once to preinstall its bundled modules, then install your modules.
> **Note:** `logos-basecamp` does not accept `--modules-dir` or `--ui-plugins-dir` CLI flags. It manages its own data directory and preinstalls bundled modules (main_ui, package_manager, etc.) on first launch.
```bash
# Build logos-basecamp
nix build 'github:logos-co/logos-basecamp' -o basecamp-result
```
```bash
# Launch once to preinstall bundled modules, then close it
./basecamp-result/bin/logos-basecamp
```
@@ -574,6 +596,8 @@ ls ~/.local/share/Logos/
The dev build directory is named `LogosBasecampDev` (portable builds use `LogosBasecamp`).
### 8.3 Install modules with lgpm
Install your modules using `lgpm`. First, set `BASECAMP_DIR` to your platform's path:
```bash
@@ -585,9 +609,10 @@ BASECAMP_DIR="$HOME/.local/share/Logos/LogosBasecampDev"
```
```bash
# Build lgpm CLI
nix build 'github:logos-co/logos-package-manager#cli' --out-link ./pm
```
```bash
# Install core module
./pm/bin/lgpm --modules-dir "$BASECAMP_DIR/modules" \
install --file ../logos-calc-module/result-lgx/*.lgx
@@ -600,14 +625,15 @@ nix build 'github:logos-co/logos-package-manager#cli' --out-link ./pm
./basecamp-result/bin/logos-basecamp
```
### 7.3 Portable basecamp build (optional)
### 8.4 Portable basecamp build (optional)
The dev build above depends on nix store paths at runtime. For a self-contained portable build that works without nix:
```bash
# Build portable basecamp (bundles all Qt frameworks/libraries)
nix build 'github:logos-co/logos-basecamp#bin-bundle-dir' -o basecamp-portable
```
```bash
# Launch once to preinstall bundled modules
./basecamp-portable/bin/logos-basecamp
```
@@ -639,7 +665,7 @@ Install your modules using the **portable** `.lgx` variants:
> **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.
### 7.4 Install via logos-basecamp UI
### 8.5 Install via logos-basecamp UI
Instead of using `lgpm` on the command line, you can install modules through the basecamp UI:
@@ -651,7 +677,7 @@ Instead of using `lgpm` on the command line, you can install modules through the
The "Calculator UI" tab appears in the sidebar. Clicking it loads your `Main.qml`.
### 7.5 Live reloading with `logos-standalone-app`
### 8.6 Live reloading with `logos-standalone-app`
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):
@@ -684,7 +710,7 @@ DEV_QML_PATH=$PWD ./result/bin/run-logos-standalone-ui
> 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.
### 7.6 Testing without any runtime
### 8.7 Testing without any runtime
You can open `Main.qml` in any QML viewer (e.g., `qml` from Qt) to test the layout.
@@ -713,13 +739,13 @@ qml6 Main.qml
---
## Step 8: UI Integration Tests (Optional)
## Step 9: UI Integration Tests
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.
### 8.1 Create a test file
### 9.1 Create a test file
Create `tests/ui-tests.mjs`:
@@ -737,7 +763,7 @@ const { test, run } = await import(
test("calc_ui: loads and shows title", async (app) => {
await app.waitFor(
async () => {
await app.expectTexts(["Calculator"]);
await app.expectTexts(["Logos Calculator"]);
},
{ timeout: 15000, interval: 500, description: "calc_ui to load" },
);
@@ -747,38 +773,41 @@ test("calc_ui: add button visible", async (app) => {
await app.expectTexts(["Add"]);
});
test("calc_ui: click add and check result", async (app) => {
test("calc_ui: click add shows validation", async (app) => {
await app.click("Add");
// Verify the result appears (depends on your UI)
await app.waitFor(
async () => {
await app.expectTexts(["Result:"]);
await app.expectTexts(["Enter values for a and b"]);
},
{ timeout: 5000, interval: 500, description: "result to appear" },
{ timeout: 5000, interval: 500, description: "validation message to appear" },
);
});
run();
```
### 8.2 Run the tests
### 9.2 Run the tests
```bash
git add tests/
```
# Hermetic CI test (builds everything, runs headless)
```bash
nix build .#integration-test -L
# Interactive: build test framework, run against a running app
nix build .#test-framework -o result-mcp
nix run . # start the app with inspector on :3768
node tests/ui-tests.mjs # in another terminal
```
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
@@ -822,8 +851,6 @@ rm -rf ~/.local/share/Logos/LogosBasecampDev
Then reinstall your custom modules.
---
## Recap
| | Core Module (Part 1) | QML UI Plugin (Part 2) |
@@ -835,8 +862,6 @@ Then reinstall your custom modules.
| Test command | `logoscore -m ./result/lib -l calc_module` | `nix run .` |
| Calls other modules | Via `LogosAPI*` (C++) | Via `logos.callModule()` (JS) |
---
## What's Next
- **Add more methods** to `calc_module` and call them from QML
+116 -94
View File
@@ -13,16 +13,15 @@ This tutorial walks you through wrapping a C shared library (`.so` on Linux, `.d
## Prerequisites
- **Nix** with flakes enabled. Install from [nixos.org](https://nixos.org/download.html), then enable flakes globally:
```bash
# Add to ~/.config/nix/nix.conf (create the file if it doesn't exist):
mkdir -p ~/.config/nix
echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf
```
Verify it works:
```bash
nix flake --help >/dev/null 2>&1 && echo "Flakes enabled" || echo "Flakes NOT enabled — check nix.conf"
```
- **Nix** with flakes enabled. Install from [nixos.org](https://nixos.org/download.html), then enable flakes:
```bash
mkdir -p ~/.config/nix
echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf
```
Verify: `nix flake --help >/dev/null 2>&1 && echo "Flakes enabled"`
- **A C compiler** (gcc or clang) for building the C library. Only needed if you're building the `.so`/`.dylib` yourself rather than using a pre-built library.
- Basic familiarity with C and C++.
@@ -34,28 +33,36 @@ Before writing any C code, scaffold the Logos module project using the official
### 1.1 Create the project using the module builder template
For a module that wraps an external C library:
`mkdir logos-calc-module && cd logos-calc-module`
```bash
# For a module that wraps an external C library:
mkdir logos-calc-module && cd logos-calc-module
nix flake init -t github:logos-co/logos-module-builder#with-external-lib
# Or for a plain module (no external library):
# nix flake init -t github:logos-co/logos-module-builder
```
> **Note:** The generated `flake.nix` uses an unpinned `logos-module-builder` URL. Replace it with the pinned version shown in [Step 2.3](#23-flakenix--nix-build-config) to ensure reproducible builds.
This generates the skeleton files (`flake.nix`, `metadata.json`, `CMakeLists.txt`, etc.) pre-configured for the logos-module-builder. You then customize them for your specific library.
> **Note:** The generated `flake.nix` uses an unpinned `logos-module-builder` URL. Replace it with the pinned version shown in the flake.nix step below to ensure reproducible builds.
> **Alternative approach:** You can also create the C library as a separate project, build it there, then copy the resulting `.so`/`.dylib` and header files into the module's `lib/` directory. This can be cleaner for larger libraries with their own build systems.
### 1.2 Create the lib directory
---
## Step 2: Write the C Library
Create the C library that your module will wrap. Place the header and implementation in the `lib/` directory.
### 2.1 Create the lib directory
```bash
mkdir -p lib
```
### 1.3 Write the C header
### 2.2 Write the C header
Create `lib/libcalc.h`:
@@ -91,7 +98,7 @@ const char* calc_version(void);
The `extern "C"` block is essential — it prevents C++ name mangling so the Logos module can find the symbols.
### 1.4 Write the C implementation
### 2.3 Write the C implementation
Create `lib/libcalc.c`:
@@ -139,7 +146,7 @@ const char* calc_version(void)
}
```
### 1.5 Build the shared library
### 2.4 Build the shared library
```bash
cd lib
@@ -177,9 +184,11 @@ You should see each symbol marked with `T` (text/code section). Addresses will v
---
## Step 2: Configure the Logos Module
## Step 3: Configure the Logos Module
The template from Step 1.1 generated skeleton files with placeholder names (`external_lib`, `example_lib`). Now rename and customize them for your library. You need to edit **every generated file**:
The template generated skeleton files with placeholder names (`external_lib`, `example_lib`). Now rename and customize them for your library. You need to edit **every generated file**.
After editing, your project should look like this:
| File | What to change |
| ---------------------- | ----------------------------------------------------------------- |
@@ -188,24 +197,21 @@ The template from Step 1.1 generated skeleton files with placeholder names (`ext
| `flake.nix` | Description (and dependency inputs if needed) |
| `src/*.h`, `src/*.cpp` | Rename files, replace class/method names, add your wrapping logic |
After renaming and editing, your project should look like this:
```
logos-calc-module/
├── flake.nix # Nix build configuration (~10 lines)
├── metadata.json # Module metadata, build settings, and runtime config (~25 lines)
├── CMakeLists.txt # CMake build file (~20 lines)
├── metadata.json # Module metadata, build settings, and runtime config
├── CMakeLists.txt # CMake build file
├── lib/
│ ├── libcalc.h # C library header
── libcalc.c # C library source
│ └── libcalc.so # Pre-built shared library
── libcalc.c # C library source (compiled by CMake)
└── src/
├── calc_module_interface.h # Interface declaration
├── calc_module_plugin.h # Plugin header
└── calc_module_plugin.cpp # Plugin implementation (wrapping logic)
```
### 2.1 `metadata.json` — Module Configuration
### 3.1 `metadata.json` — Module Configuration
> **Edit:** Change `name`, `description`, `main`, `nix.external_libraries[].name`, and `nix.cmake.extra_include_dirs` to match your module and library.
@@ -247,11 +253,10 @@ This is the single source of truth for your module. It is embedded into the plug
| Field | What it does |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | Module name — must be a valid C identifier (used in filenames, method calls) |
| `nix.external_libraries[].name` | Library name **without the `lib` prefix** — the builder looks for `lib<name>.so` / `lib<name>.dylib` in the directory specified by `vendor_path`. So `name: calc` matches the file `libcalc.so` / `libcalc.dylib`. This follows the standard Unix library naming convention where `-lcalc` links against `libcalc`. |
| `nix.external_libraries[].vendor_path` | Where to find the pre-built library. `"lib"` means the `lib/` directory in your project root |
| `nix.cmake.extra_include_dirs` | Added to the CMake include path so your C++ code can `#include "lib/libcalc.h"` |
| `nix.external_libraries` | Declares C/C++ libraries vendored in the repo. Each entry has a `name` (used for the Nix derivation and CMake target) and `vendor_path` (directory containing the source). The build system compiles the library and makes it available as a CMake target |
| `nix.cmake.extra_include_dirs` | Added to the CMake include path so your C++ code can `#include "libcalc.h"` |
### 2.2 `CMakeLists.txt` — Build File
### 3.2 `CMakeLists.txt` — Build File
> **Edit:** Change `project()` name, `NAME`, `SOURCES` filenames, and `EXTERNAL_LIBS` to match your module and library.
@@ -293,9 +298,9 @@ The `if/elseif/else` block above it is boilerplate — don't change it.
**How `EXTERNAL_LIBS calc` works:** The `logos_module()` CMake function searches `lib/` for `libcalc.so` (Linux) or `libcalc.dylib` (macOS), links it to your plugin, and sets up RPATH so the library is found at runtime.
### 2.3 `flake.nix` — Nix Build Config
### 3.3 `flake.nix` — Nix Build Config
> **Edit:** Change `description`. Add flake inputs here if your module depends on other modules or fetches a library from source (see [Advanced: Wrapping a Library from a Flake Input](#advanced-wrapping-a-library-from-a-flake-input)).
Change `description`. Add flake inputs here if your module depends on other modules or fetches a library from source.
```nix
{
@@ -316,13 +321,11 @@ The `if/elseif/else` block above it is boilerplate — don't change it.
That's it — `mkLogosModule` handles all the Nix complexity (fetching Qt, the SDK, the code generator, setting up include paths, etc.). Note that `configFile` points to `metadata.json` (the single source of truth) and `flakeInputs = inputs` passes all flake inputs to the builder so that dependencies declared in `metadata.json` are resolved automatically.
> **Naming flake inputs:** When adding module dependencies, the flake input attribute name **must match** the `name` field in that dependency's `metadata.json`. For example, if you depend on a module whose `metadata.json` has `"name": "waku_module"`, your flake input must be `waku_module.url = "github:logos-co/logos-waku-module"`. The URL can point to any repo, but the attribute name is how the builder resolves dependencies.
> **Naming flake inputs:** When adding module dependencies, the flake input attribute name **must match** the `name` field in that dependency's `metadata.json`. For example, if you depend on a module whose `metadata.json` has `"name": "waku_module"`, your flake input must be `waku_module.url = "github:logos-co/logos-waku-module"`.
### 2.4 `src/calc_module_interface.h` — Interface Declaration
### 3.4 `src/calc_module_interface.h` — Interface Declaration
> **Edit:** Rename from `external_lib_interface.h`. Replace the class name, interface ID, include guard, and declare your module's methods as `Q_INVOKABLE virtual` pure-virtual functions.
This declares the methods your module exposes. It inherits from `PluginInterface` (provided by the Logos C++ SDK).
This declares the methods your module exposes. It inherits from `PluginInterface` (provided by the Logos C++ SDK). Every method you want callable by other modules must be `Q_INVOKABLE` and `virtual`.
```cpp
#ifndef CALC_MODULE_INTERFACE_H
@@ -356,9 +359,7 @@ Q_DECLARE_INTERFACE(CalcModuleInterface, CalcModuleInterface_iid)
- Supported parameter/return types: `int`, `bool`, `QString`, `QByteArray`, `QVariant`, `QJsonArray`, `QStringList`, `LogosResult`
- The interface ID string (e.g., `"org.logos.CalcModuleInterface"`) must be unique across all modules
### 2.5 `src/calc_module_plugin.h` — Plugin Header
> **Edit:** Rename from `external_lib_plugin.h`. Replace class name, interface references, `name()`/`version()` return values, and declare your `Q_INVOKABLE` wrapper methods. Add `#include` for your C library header.
### 3.5 `src/calc_module_plugin.h` — Plugin Header
This is the actual plugin class. It inherits from both `QObject` (for Qt's meta-object system) and your interface.
@@ -385,15 +386,13 @@ public:
explicit CalcModulePlugin(QObject* parent = nullptr);
~CalcModulePlugin() override;
// PluginInterface — required by every module
// PluginInterface
QString name() const override { return "calc_module"; }
QString version() const override { return "1.0.0"; }
// Called by the Logos host when the module is loaded.
// NOT marked override — it is invoked reflectively via QMetaObject.
Q_INVOKABLE void initLogos(LogosAPI* api);
// CalcModuleInterface — each wraps a libcalc C function
// CalcModuleInterface
Q_INVOKABLE int add(int a, int b) override;
Q_INVOKABLE int multiply(int a, int b) override;
Q_INVOKABLE int factorial(int n) override;
@@ -416,11 +415,9 @@ signals:
- `initLogos` must be `Q_INVOKABLE` but **not** `override` — the base class `PluginInterface` does not declare it as virtual; the Logos host calls it reflectively via `QMetaObject::invokeMethod`
- `eventResponse` signal is required for event forwarding between modules. Emit it to push data to subscribers (e.g., QML UIs listening via `logos.onModuleEvent()`)
- `name()` must return the same string as the `name` field in `metadata.json`
- **No `m_logosAPI` member variable** — the `LogosAPI`\* pointer is stored in the global `logosAPI` variable defined in `liblogos`, not in a class member. See the `initLogos` implementation below.
- **No `m_logosAPI` member variable** — the `LogosAPI*` pointer is stored in the global `logosAPI` variable defined in `liblogos`, not in a class member. See the `initLogos` implementation below.
### 2.6 `src/calc_module_plugin.cpp` — Plugin Implementation
> **Edit:** Rename from `external_lib_plugin.cpp`. Replace the placeholder implementations with actual calls to your C library functions.
### 3.6 `src/calc_module_plugin.cpp` — Plugin Implementation
This is where the wrapping happens. Each method calls the corresponding C function.
@@ -442,16 +439,12 @@ CalcModulePlugin::~CalcModulePlugin()
void CalcModulePlugin::initLogos(LogosAPI* api)
{
// IMPORTANT: Use the global `logosAPI` variable from liblogos, NOT a class member.
// `logosAPI` is defined in the Logos SDK headers and is used by the API
// internally. Storing the pointer in a local `m_logosAPI` member will NOT work.
logosAPI = api;
qDebug() << "CalcModulePlugin: LogosAPI initialized";
}
int CalcModulePlugin::add(int a, int b)
{
// Call the C library function
int result = calc_add(a, b);
qDebug() << "CalcModulePlugin::add" << a << "+" << b << "=" << result;
return result;
@@ -498,20 +491,20 @@ void CalcModulePlugin::libVersionNotify()
**The wrapping pattern** is always the same:
1. Call the C function with the arguments
2. Convert the C result to a Qt type if needed (e.g., `const char`\* → `QString`)
2. Convert the C result to a Qt type if needed (e.g., `const char*``QString`)
3. Return the Qt type
---
## Step 3: Build the Module
## Step 4: Build the Module
### 3.1 Initialize the Git repo
### 4.1 Initialize the Git repo
Nix flakes require a git repository.
Before staging files, create a `.gitignore` to exclude build artifacts:
```
```text
# Nix build output
result
result-*
@@ -523,28 +516,42 @@ build/
Then initialise the repo:
```bash
cd logos-calc-module
git init
```
```bash
git add -A
```
```bash
nix flake update
```
```bash
git add flake.lock
```
### 3.2 Build with Nix
### 4.2 Build the plugin library
Build just the plugin library (`.so` / `.dylib`):
```bash
# Build just the plugin library (.so / .dylib)
nix build '.#lib'
```
# Build everything (library + generated SDK headers)
> **Quoting matters:** Use `'.#lib'` (with quotes) rather than bare `nix build .#lib`. Some shells (especially zsh) may interpret the `#` as a comment character.
The first build takes a while (515 minutes) as Nix downloads Qt, the Logos SDK, and other dependencies. Subsequent builds are fast due to caching.
### 4.3 Build the full package
Build everything (library + generated SDK headers):
```bash
nix build
```
> **Quoting matters:** Use `'.#lib'` (with quotes) rather than bare `nix build .#lib`. Some shells (especially zsh) may interpret the `#` as a comment character, causing the command to silently build the wrong thing or fail.
The first build takes a while (5-15 minutes) as Nix downloads Qt, the Logos SDK, and other dependencies. Subsequent builds are fast due to caching.
### 3.3 Inspect the output
### 4.4 Inspect the output
```bash
ls -la result/lib/
@@ -566,17 +573,19 @@ Both library files are placed together so the plugin can find the C library at r
---
## Step 4: Inspect the Module
## Step 5: Inspect the Module
### 4.1 Build the `lm` tool
Use the `lm` CLI tool (from `logos-module`) to inspect the compiled module binary.
The `lm` CLI tool (from `logos-module`) inspects compiled module binaries:
### 5.1 Build the `lm` tool
The `lm` CLI inspects compiled module binaries. Build it from the `logos-module` repo:
```bash
nix build 'github:logos-co/logos-module#lm' --out-link ./lm
```
### 4.2 View metadata
### 5.2 View metadata
```bash
# Linux
@@ -599,7 +608,7 @@ Type: core
Dependencies: (none)
```
### 4.3 List methods
### 5.3 List methods
```bash
# Linux
@@ -646,7 +655,7 @@ QString libVersion()
All five wrapping methods are visible and invokable. The `initLogos` method is automatically called by the Logos host when loading the module.
### 4.4 JSON output
### 5.4 JSON output
For scripting and CI, use `--json`:
@@ -676,25 +685,31 @@ For scripting and CI, use `--json`:
---
## Step 5: Test with `logoscore`
## Step 6: Test with `logoscore`
### 5.1 Build logoscore
### 6.1 Build logoscore
```bash
nix build 'github:logos-co/logos-logoscore-cli' --out-link ./logos
```
### 5.2 Set up the modules directory
### 6.2 Set up the modules directory
`logoscore` expects modules in subdirectories, each with a `manifest.json`. Rather than copying files and writing the manifest manually, use the Nix derivation to create an LGX package and install it with the package manager:
```bash
# Bundle the module into an LGX package
nix build '.#lgx'
```
# Install it into a modules directory using the Logos Package Manager
```bash
nix build 'github:logos-co/logos-package-manager#cli' --out-link ./pm
```
```bash
mkdir -p modules
```
```bash
./pm/bin/lgpm --modules-dir ./modules install --file result/*.lgx
```
@@ -708,24 +723,39 @@ modules/calc_module/
└── variant # Platform variant identifier
```
### 5.3 Call methods
### 6.3 Call methods
Start the daemon and call methods:
```bash
# Start logoscore daemon with modules directory
./logos/bin/logoscore -D -m ./modules &
```
# Load the module
```bash
sleep 3
```
```bash
./logos/bin/logoscore load-module calc_module
```
# Call methods
```bash
./logos/bin/logoscore call calc_module add 3 5
./logos/bin/logoscore call calc_module factorial 5
./logos/bin/logoscore call calc_module fibonacci 10
./logos/bin/logoscore call calc_module libVersion
```
# Stop the daemon when done
```bash
./logos/bin/logoscore call calc_module factorial 5
```
```bash
./logos/bin/logoscore call calc_module fibonacci 10
```
```bash
./logos/bin/logoscore call calc_module libVersion
```
```bash
./logos/bin/logoscore stop
```
@@ -757,7 +787,7 @@ Method call successful. Result: ...
---
## Step 6: Package for Distribution (Optional)
## Package for Distribution (Optional)
The LGX package created in Step 5.2 is a **local** package — its libraries still reference `/nix/store` paths, so it only works on the machine that built it. To create a **portable** package that can be distributed to other machines:
@@ -785,8 +815,6 @@ nix build 'github:logos-co/logos-package-manager#cli' --out-link ./pm
> **Note:** Local builds of `logoscore` / `logos-basecamp` (via `nix build`) expect **local** `.lgx` packages. Portable builds (via `nix build '.#bin-bundle-dir'`, `.#bin-appimage`, or `.#bin-macos-app`) expect **portable** `.lgx` packages. See the [logos-basecamp README](https://github.com/logos-co/logos-basecamp/blob/master/README.md) for details.
---
## Common Wrapping Patterns
### Wrapping C functions with opaque pointers
@@ -881,8 +909,6 @@ Q_INVOKABLE QString getData() {
| `bool` / `int` | `bool` | `result != 0` | direct |
| `void*` | (store in member) | — | — |
---
## Advanced: Wrapping a Library from a Flake Input
Instead of pre-building the library and placing it in `lib/`, you can have Nix fetch and build it from source. This is useful for libraries hosted on GitHub.
@@ -977,8 +1003,6 @@ If the external library is written in Go with C bindings (`cgo`), set `go_build:
Setting `go_build: true` enables the Go toolchain and sets `CGO_ENABLED=1`.
---
## Real-World Example: logos-libp2p-module
The [logos-libp2p-module](https://github.com/logos-co/logos-libp2p-module) is a production module that wraps the `nim-libp2p` library (compiled to a C shared library). Key files:
@@ -990,8 +1014,6 @@ The [logos-libp2p-module](https://github.com/logos-co/logos-libp2p-module) is a
It follows the exact same pattern as this tutorial, just at a larger scale.
---
## Troubleshooting
### `initLogos` marked 'override', but does not override