Files
logos-tutorial/tutorial-qml-ui-app.md

16 KiB

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
  • 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 always 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#ui-qml-module
git init && git add -A

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:

{
  "name": "calc_ui",
  "version": "1.0.0",
  "description": "Calculator UI - QML frontend for the calc_module",
  "type": "ui_qml",
  "main": "Main.qml",
  "dependencies": ["calc_module"],
  "category": "tools",
  "icon": "icons/calc.png"
}

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

Replace the starter file with the calculator UI:

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

Item {
    id: root

    property string result: ""
    property string errorText: ""

    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
        }

        // ── Two-operand operations ─────────────────────────────
        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)
            }
        }

        // ── Single-operand operations ──────────────────────────
        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", [])
            }
        }

        // ── Result display ─────────────────────────────────────
        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 }
    }

    // ── Logos bridge 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 logos object is injected by the host at runtime. The callModule helper checks for it and routes calls through the IPC bridge to calc_module.


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";
    logos-standalone-app.url = "github:logos-co/logos-standalone-app";
    calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module";  # must match dependency name in metadata.json
  };

  outputs = inputs@{ logos-module-builder, logos-standalone-app, ... }:
    logos-module-builder.lib.mkLogosQmlModule {
      src = ./.;
      configFile = ./metadata.json;
      flakeInputs = inputs;
      logosStandalone = logos-standalone-app;
    };
}

mkLogosQmlModule handles everything — it stages QML files, metadata, and icons into a plugin directory. logosStandalone wires up apps.default so nix run . launches the UI in a standalone window. flakeInputs = inputs passes all inputs so that dependencies declared in metadata.json are resolved automatically — note that the input attribute name (calc_module) must match the dependency name.


Step 5: Test with nix run

5.1 UI only (layout preview)

git add -A
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)

To test actual calls to calc_module, you need a modules directory with capability_module (required by all UI plugins) and calc_module installed via lgpm. Do this once:

nix build 'github:logos-co/logos-package-manager-module#cli' --out-link ./pm
mkdir -p modules

# Install capability_module (required by all UI plugins)
nix bundle --bundler 'github:logos-co/nix-bundle-lgx' \
  'github:logos-co/logos-capability-module' -o lgx-capability
./pm/bin/lgpm --modules-dir ./modules install --file lgx-capability/*.lgx

# Bundle and install calc_module (from Part 1)
cd ../logos-calc-module
nix bundle --bundler 'github:logos-co/nix-bundle-lgx' '.#lib' -o lgx-result
cd ../logos-calc-ui
./pm/bin/lgpm --modules-dir ./modules install --file ../logos-calc-module/lgx-result/*.lgx

Then run with the modules directory:

nix run . -- --modules-dir ./modules

Clicking Add, Multiply, Factorial, or Fibonacci now calls the real module.


Step 6: Using the Logos Design System

logos-basecamp has logos-design-system on its QML import path. You can use its themed components directly without any extra setup in your module.

import Logos.Theme 1.0
import Logos.Controls 1.0

Replace the plain Button and TextField with the styled equivalents:

// Instead of Button:
LogosButton {
    text: "Add"
    onClicked: callTwoOp("add", inputA.text, inputB.text)
}

// Instead of TextField:
LogosTextField {
    id: inputA
    placeholderText: "a"
}

// Use theme colors instead of hardcoded hex values:
Rectangle {
    color: Theme.palette.backgroundSecondary
    // ...
    Text { color: Theme.palette.text }
}

Available components: LogosButton, LogosTextField, LogosText, LogosTabButton.

Available theme tokens via Theme.palette:

  • Colors: background, backgroundSecondary, backgroundMuted, text, textMuted, border, overlayOrange
  • Spacing: Theme.spacing.radiusSmall, Theme.spacing.radiusXlarge
  • Typography: Theme.typography.secondaryText, Theme.typography.weightMedium

Note: Logos.Theme and Logos.Controls are only available when running inside logos-basecamp. They are not available in logos-standalone-app. Use them only if you know your module will run in basecamp, or guard the import.


Step 7: Load in logos-basecamp

7.1 Create LGX packages

Bundle both modules as portable LGX files:

# Package calc_module (from Part 1)
cd ../logos-calc-module
nix bundle --bundler 'github:logos-co/nix-bundle-lgx#portable' '.#lib' -o lgx-calc-module
cd ../logos-calc-ui

# Package the QML UI plugin
nix bundle --bundler 'github:logos-co/nix-bundle-lgx#portable' '.' -o lgx-calc-ui

7.2 Install via logos-basecamp UI

  1. Open logos-basecamp
  2. Go to Package Manager
  3. Click Install from file
  4. Select lgx-calc-module/*.lgx — installs calc_module
  5. Repeat for lgx-calc-ui/*.lgx — installs calc_ui

The "Calculator UI" tab appears in the sidebar. Clicking it loads your Main.qml.

7.3 Install via CLI (alternative)

nix build 'github:logos-co/logos-package-manager-module#cli' --out-link ./pm
./pm/bin/lgpm install --file lgx-calc-module/*.lgx
./pm/bin/lgpm install --file lgx-calc-ui/*.lgx

7.4 Build logos-basecamp from source

Build a local logos-basecamp binary, then use lgpm to populate a modules directory and run it:

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

# Create module directories
mkdir -p modules ui-plugins

# Build lgpm CLI
nix build 'github:logos-co/logos-package-manager-module#cli' --out-link ./pm

# Install capability_module (required by all UI plugins)
nix bundle --bundler 'github:logos-co/nix-bundle-lgx' \
  'github:logos-co/logos-capability-module' -o lgx-capability
./pm/bin/lgpm --modules-dir ./modules install --file lgx-capability/*.lgx

# Bundle and install calc_module (local, not portable)
cd ../logos-calc-module
nix bundle --bundler 'github:logos-co/nix-bundle-lgx' '.#lib' -o lgx-calc-module-local
cd ../logos-calc-ui
./pm/bin/lgpm --modules-dir ./modules install --file ../logos-calc-module/lgx-calc-module-local/*.lgx

# Bundle and install the QML UI plugin
nix bundle --bundler 'github:logos-co/nix-bundle-lgx' '.' -o lgx-calc-ui-local
./pm/bin/lgpm --modules-dir ./ui-plugins install --file lgx-calc-ui-local/*.lgx

# Run basecamp with the populated directories
./basecamp-result/bin/logos-basecamp \
  --modules-dir ./modules \
  --ui-plugins-dir ./ui-plugins

Local vs portable: A locally-built logos-basecamp (via nix build) expects local .lgx packages (built without #portable). Portable builds (AppImage, macOS app bundle) expect portable .lgx packages.

7.5 Live reloading

For rapid iteration on QML, use development mode. This watches your source files and reloads on change:

QML_UI=$(pwd) logos-basecamp

Edit Main.qml, save, and the UI updates without rebuilding.

7.6 Testing without logos-basecamp

You can open Main.qml in any QML viewer (e.g., qml from Qt) to test the layout. 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 installed
qml Main.qml

Step 8: Package for Distribution (Optional)

The LGX packages created in Step 7.2 are local packages — they contain /nix/store references and work on the machine that built them. To create portable packages for distribution to other machines, use the #portable bundler:

# Portable core module
cd ../logos-calc-module
nix bundle --bundler 'github:logos-co/nix-bundle-lgx#portable' '.#lib' -o lgx-portable

# Portable QML UI plugin
cd ../logos-calc-ui
nix bundle --bundler 'github:logos-co/nix-bundle-lgx#portable' '.' -o lgx-portable

Portable LGX packages are fully self-contained and can be installed on any machine with the Logos Package Manager:

nix build 'github:logos-co/logos-package-manager-module#cli' --out-link ./pm
./pm/bin/lgpm --modules-dir ./modules install --file calc_module.lgx
./pm/bin/lgpm --modules-dir ./ui-plugins install --file calc_ui.lgx

Local vs portable: Local builds of logos-basecamp (via nix build) expect local .lgx packages. Portable builds (AppImage, macOS app bundle) expect portable .lgx packages. See the logos-basecamp README for details.


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