Files
logos-tutorial/tutorial-qml-ui-app.md
Dario Gabriel LipicarandClaude Opus 4.7 13575547c9 feat: pin tutorial references to tutorial-v2
- Pin all logos-co repo refs in the 4 guides to /tutorial-v2
- Fix broken cross-doc anchors (#61-running-with-logoscore,
  #42-building-lgx-packages)
- Sync logos-calc-ui-cpp example with Part 3: add status PROP to
  calc_ui_cpp.rep and status binding/display to Main.qml

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:06:28 -03:00

32 KiB
Raw Permalink Blame History

Tutorial Part 2: Building a QML UI for Your Logos Module

This is Part 2 of the Logos module tutorial series. In Part 1 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'll 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'll 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 — 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)

How QML UI Plugins Work

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

Use the QML module template from logos-module-builder:

mkdir logos-calc-ui && cd logos-calc-ui
nix flake init -t github:logos-co/logos-module-builder/tutorial-v2#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 to ensure reproducible builds.

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

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:

{
  "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": []
    }
  }
}

Create the icon directory and add a placeholder icon. The icon is displayed in the logos-basecamp sidebar when the module is loaded:

mkdir -p icons
# Copy any PNG here — or generate a 64×64 placeholder:
echo "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAmElEQVR4nO3QMREAIBDAsFeEN3ziCWRkoEP2XmedfX82OkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAK0BOkBrgA7QGqADtAboAO0BN/SiO/PatoIAAAAASUVORK5CYII=" | base64 -d > icons/calc.png

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/tutorial-v2?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

Replace the starter file with the calculator UI. This demonstrates two communication patterns:

  1. Direct callslogos.callModule() sends a request and returns the result immediately
  2. Event-basedlogos.callModule() fires-and-forgets, the module emits an event, and QML receives it via logos.onModuleEvent()
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)])
    }
}

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

The template already has everything wired up. Update the description and add calc_module as a dependency input:

{
  description = "Calculator QML UI Plugin for Logos - frontend for calc_module";

  inputs = {
    logos-module-builder.url = "github:logos-co/logos-module-builder/tutorial-v2";

    # Option A: point to a remote repo (for CI or when calc_module is published)
    calc_module.url = "github:logos-co/logos-tutorial/tutorial-v2?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;
    };
}

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 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.


Step 5: Test with nix run

5.1 UI only (layout preview)

git add -A
nix flake update   # regenerate flake.lock to match the pinned inputs in flake.nix
git add flake.lock
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)

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

Go back to your logos-calc-module directory and verify the shared library exists:

ls ../logos-calc-module/lib/libcalc.so    # Linux
ls ../logos-calc-module/lib/libcalc.dylib  # macOS

If the file is missing, build it first (as covered in Part 1, Step 1.5):

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

Also make sure the module itself builds successfully:

cd ../logos-calc-module
git add -A
nix build
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)

If your flake.nix points to a github: URL, you can override it at build time to use your local checkout:

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)

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:

# From remote:
calc_module.url = "github:logos-co/logos-tutorial/tutorial-v2?dir=logos-calc-module";
# To local:
calc_module.url = "path:../logos-calc-module";

Then run normally without overrides:

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.

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:

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

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.

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:

cd repos/logos-design-system
nix run                  # or: ws run logos-design-system

The sidebar splits components into two sections:

  • Controlsdesigned 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

// 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

// 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

7.1 Bundle as LGX packages

Create .lgx packages for both dev and portable variants. Use --out-link to avoid overwriting the result symlink:

# 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

For more bundling options (standalone bundler syntax, cross-platform packaging), see the Developer Guide — Building LGX Packages.

7.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.

# Build logos-basecamp
nix build 'github:logos-co/logos-basecamp/tutorial-v2' -o basecamp-result

# 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:

# 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).

Install your modules using lgpm. First, set BASECAMP_DIR to your platform's path:

# macOS:
BASECAMP_DIR="$HOME/Library/Application Support/Logos/LogosBasecampDev"

# Linux:
BASECAMP_DIR="$HOME/.local/share/Logos/LogosBasecampDev"
# Build lgpm CLI
nix build 'github:logos-co/logos-package-manager/tutorial-v2#cli' --out-link ./pm

# 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

7.3 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:

# Build portable basecamp (bundles all Qt frameworks/libraries)
nix build 'github:logos-co/logos-basecamp/tutorial-v2#bin-bundle-dir' -o basecamp-portable

# 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:

# 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:

# 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.

7.4 Install via logos-basecamp UI

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.

7.5 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):

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:

# 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.

7.6 Testing without any runtime

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):

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.

# If you have Qt and included modules installed
# macOS:
qml Main.qml

# Linux:
qml6 Main.qml

Step 8: UI Integration Tests (Optional)

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 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

Create tests/ui-tests.mjs:

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(["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 and check result", async (app) => {
  await app.click("Add");
  // Verify the result appears (depends on your UI)
  await app.waitFor(
    async () => {
      await app.expectTexts(["Result:"]);
    },
    { timeout: 5000, interval: 500, description: "result to appear" },
  );
});

run();

8.2 Run the tests

git add tests/

# Hermetic CI test (builds everything, runs headless)
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.


Known Limitations

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:

// 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:

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:

# 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

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

  • 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, Section 7.2