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

26 KiB

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

This is Part 2 of the Logos module tutorial series. In Part 1, you wrapped a C library (libcalc) as a Logos module. Now you'll build a QML user interface that calls that module from inside the Logos desktop app.

What you'll build: A calc_ui QML app with input fields, buttons, and a result display that calls 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 build, install, and run your UI inside logos-app

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      |
|    (QML, sandboxed)|       IPC (Qt Remote Objects)  |   (C++ plugin)     |
|    Main.qml        |                                |   wraps libcalc.so |
+--------------------+                                +--------------------+
        ^                                                      ^
        |  loaded by                                           |  loaded by
        v                                                      v
+---------------------------------------------------------------+
|                        logos-app                               |
|   QML sandbox engine        |        logos_host process       |
+---------------------------------------------------------------+

Key points:

  1. QML plugins are pure QML — no C++ compilation needed. You write .qml files and a metadata.json.
  2. QML plugins are sandboxed — no network access, no filesystem access outside the module directory.
  3. The logos bridge is injected by the host. You call core modules with logos.callModule("module_name", "method", [args]).
  4. The entry point is always Main.qml. The host loads this file into a sandboxed QML engine.

Step 1: Create the Project

The project structure is minimal:

logos-calc-ui/
├── Main.qml          # The UI (your only code file)
├── metadata.json     # Plugin metadata
├── flake.nix         # Nix build config
└── icons/
    └── calc.png      # Tab icon (optional)

1.1 Create the directory

mkdir logos-calc-ui && cd logos-calc-ui
mkdir icons

1.2 Add an icon (optional)

Place a PNG icon at icons/calc.png. This appears in the logos-app sidebar. If you don't have one, the app will use a default icon.


Step 2: Write metadata.json

This tells logos-app what your plugin is and how to load it.

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

Key fields explained:

Field What it does
type Must be "ui_qml" for QML UI plugins
pluginType Must be "qml"
main Entry point QML file — always "Main.qml"
dependencies Core modules this UI needs. logos-app loads these before your UI.
category Groups plugins in the sidebar (e.g., "tools", "misc")
icon Path to the sidebar icon, relative to the plugin directory

Compare with calc_module's metadata (from Part 1):

{
  "name": "calc_module",
  "type": "core",
  "main": "calc_module_plugin",
  ...
}

The core module has "type": "core" and "main" points to a compiled plugin binary. The QML UI has "type": "ui_qml" and "main" points to a .qml file. Different types, same metadata format.


Step 3: Write Main.qml

This is the entire UI. The complete file is at section 3.3

3.1 Scaffold

Create Main.qml:

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

Item {
    id: root

    // State
    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: "#1f2328"
            Layout.alignment: Qt.AlignHCenter
        }

        Text {
            text: "QML frontend for the calc_module (libcalc C library)"
            font.pixelSize: 13
            color: "#57606a"
            Layout.alignment: Qt.AlignHCenter
        }

        // ... sections go here ...

        // Push everything up
        Item { Layout.fillHeight: true }
    }

    // ... helper functions go here ...
}

The root Item with a ColumnLayout is the standard pattern. Two properties track the result and any error.

3.2 The Logos bridge function

This is the most important part — the function that calls your core module. Add this at the bottom of the Item, after the ColumnLayout:

    function callModule(method, args) {
        root.errorText = ""
        root.result = ""

        // The logos object is injected by the host at runtime.
        // It won't exist if you open Main.qml in a standalone QML viewer.
        if (typeof logos === "undefined" || !logos.callModule) {
            root.errorText = "Logos bridge not available (run inside logos-app)"
            return
        }

        var res = logos.callModule("calc_module", method, args)
        root.result = String(res)
    }

How logos.callModule() works:

logos.callModule(moduleName, methodName, argsArray)
                    │            │           │
                    │            │           └── JavaScript array of arguments
                    │            └── Method name on the module (e.g., "add")
                    └── Module name from metadata.json (e.g., "calc_module")

The call is synchronous from QML's perspective. Under the hood, logos-app routes it via IPC to the logos_host process running calc_module, which calls CalcModulePlugin::add(), which calls calc_add() from libcalc. The result comes back through the same chain.

3.3 The complete file

Here is the complete Main.qml with all sections assembled:

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

Item {
    id: root

    // State
    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: "#1f2328"
            Layout.alignment: Qt.AlignHCenter
        }

        Text {
            text: "QML frontend for the calc_module (libcalc C library)"
            font.pixelSize: 13
            color: "#57606a"
            Layout.alignment: Qt.AlignHCenter
        }

        // ── Two-operand section ────────────────────────────────
        Rectangle {
            Layout.fillWidth: true
            Layout.preferredHeight: twoOpColumn.implicitHeight + 32
            color: "#f6f8fa"
            radius: 8
            border.color: "#d1d9e0"
            border.width: 1

            ColumnLayout {
                id: twoOpColumn
                anchors.fill: parent
                anchors.margins: 16
                spacing: 12

                Text {
                    text: "Two-operand operations"
                    font.pixelSize: 14
                    font.weight: Font.DemiBold
                    color: "#1f2328"
                }

                RowLayout {
                    spacing: 12
                    Layout.fillWidth: true

                    TextField {
                        id: inputA
                        placeholderText: "a"
                        Layout.preferredWidth: 100
                        validator: IntValidator {}
                    }

                    TextField {
                        id: inputB
                        placeholderText: "b"
                        Layout.preferredWidth: 100
                        validator: IntValidator {}
                    }

                    Button {
                        text: "Add"
                        onClicked: callTwoOp("add", inputA.text, inputB.text)

                        background: Rectangle {
                            implicitWidth: 80
                            implicitHeight: 36
                            color: parent.pressed ? "#1a7f37" : "#238636"
                            radius: 6
                        }
                        contentItem: Text {
                            text: parent.text
                            color: "#ffffff"
                            font.pixelSize: 13
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                    }

                    Button {
                        text: "Multiply"
                        onClicked: callTwoOp("multiply", inputA.text, inputB.text)

                        background: Rectangle {
                            implicitWidth: 80
                            implicitHeight: 36
                            color: parent.pressed ? "#1a7f37" : "#238636"
                            radius: 6
                        }
                        contentItem: Text {
                            text: parent.text
                            color: "#ffffff"
                            font.pixelSize: 13
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                    }
                }
            }
        }

        // ── Single-operand section ─────────────────────────────
        Rectangle {
            Layout.fillWidth: true
            Layout.preferredHeight: oneOpColumn.implicitHeight + 32
            color: "#f6f8fa"
            radius: 8
            border.color: "#d1d9e0"
            border.width: 1

            ColumnLayout {
                id: oneOpColumn
                anchors.fill: parent
                anchors.margins: 16
                spacing: 12

                Text {
                    text: "Single-operand operations"
                    font.pixelSize: 14
                    font.weight: Font.DemiBold
                    color: "#1f2328"
                }

                RowLayout {
                    spacing: 12
                    Layout.fillWidth: true

                    TextField {
                        id: inputN
                        placeholderText: "n"
                        Layout.preferredWidth: 100
                        validator: IntValidator { bottom: 0 }
                    }

                    Button {
                        text: "Factorial"
                        onClicked: callOneOp("factorial", inputN.text)

                        background: Rectangle {
                            implicitWidth: 80
                            implicitHeight: 36
                            color: parent.pressed ? "#0a58ca" : "#0969da"
                            radius: 6
                        }
                        contentItem: Text {
                            text: parent.text
                            color: "#ffffff"
                            font.pixelSize: 13
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                    }

                    Button {
                        text: "Fibonacci"
                        onClicked: callOneOp("fibonacci", inputN.text)

                        background: Rectangle {
                            implicitWidth: 80
                            implicitHeight: 36
                            color: parent.pressed ? "#0a58ca" : "#0969da"
                            radius: 6
                        }
                        contentItem: Text {
                            text: parent.text
                            color: "#ffffff"
                            font.pixelSize: 13
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                    }
                }
            }
        }

        // ── Info section ───────────────────────────────────────
        Button {
            text: "Get libcalc version"
            onClicked: callNoArg("libVersion")

            background: Rectangle {
                implicitWidth: 160
                implicitHeight: 36
                color: parent.pressed ? "#32383f" : "#24292f"
                radius: 6
            }
            contentItem: Text {
                text: parent.text
                color: "#ffffff"
                font.pixelSize: 13
                horizontalAlignment: Text.AlignHCenter
                verticalAlignment: Text.AlignVCenter
            }
        }

        // ── Result display ─────────────────────────────────────
        Rectangle {
            Layout.fillWidth: true
            Layout.preferredHeight: 64
            color: root.errorText.length > 0 ? "#fff1f0" : "#dafbe1"
            radius: 8
            border.color: root.errorText.length > 0 ? "#ffcdd2" : "#adf0b9"
            border.width: 1

            ColumnLayout {
                anchors.fill: parent
                anchors.margins: 12
                spacing: 4

                Text {
                    text: root.errorText.length > 0 ? "Error" : "Result"
                    font.pixelSize: 12
                    font.weight: Font.DemiBold
                    color: root.errorText.length > 0 ? "#cf222e" : "#116329"
                }

                Text {
                    text: root.errorText.length > 0 ? root.errorText
                            : (root.result.length > 0 ? root.result
                                                      : "Press a button above")
                    font.pixelSize: 16
                    font.weight: Font.Medium
                    color: root.errorText.length > 0 ? "#cf222e" : "#1f2328"
                    Layout.fillWidth: true
                    elide: Text.ElideRight
                }
            }
        }

        // Push everything up
        Item { Layout.fillHeight: true }
    }

    // ── Helper functions ───────────────────────────────────────

    function callModule(method, args) {
        root.errorText = ""
        root.result = ""

        if (typeof logos === "undefined" || !logos.callModule) {
            root.errorText = "Logos bridge not available (run inside logos-app)"
            return
        }

        var res = logos.callModule("calc_module", method, args)
        root.result = String(res)
    }

    function callTwoOp(method, a, b) {
        if (a === "" || b === "") {
            root.errorText = "Enter values for both 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)])
    }

    function callNoArg(method) {
        callModule(method, [])
    }
}

Step 4: Write flake.nix

QML plugins don't need compilation — the Nix build just copies files to the output. But we still use a flake so the build process is consistent with core modules and the plugin can be consumed by other Nix expressions.

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

  inputs = {
    logos-cpp-sdk.url = "github:logos-co/logos-cpp-sdk";
    nixpkgs.follows = "logos-cpp-sdk/nixpkgs";
  };

  outputs = { self, nixpkgs, logos-cpp-sdk }:
    let
      systems = [ "aarch64-darwin" "x86_64-darwin" "aarch64-linux" "x86_64-linux" ];
      forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f {
        pkgs = import nixpkgs { inherit system; };
      });
    in
    {
      packages = forAllSystems ({ pkgs }: let
        plugin = pkgs.stdenv.mkDerivation {
          pname = "logos-calc-ui-plugin";
          version = "1.0.0";
          src = ./.;

          dontUnpack = false;
          phases = [ "unpackPhase" "installPhase" ];

          installPhase = ''
            runHook preInstall

            dest="$out/lib"
            mkdir -p "$dest/icons"

            cp $src/Main.qml    "$dest/Main.qml"
            cp $src/metadata.json "$dest/metadata.json"

            # Copy icon if present
            if [ -f "$src/icons/calc.png" ]; then
              cp $src/icons/calc.png "$dest/icons/calc.png"
            fi

            runHook postInstall
          '';

          meta = with pkgs.lib; {
            description = "Calculator QML UI Plugin for Logos";
            platforms = platforms.unix;
          };
        };
      in {
        default = plugin;
        lib = plugin;
      });
    };
}

Compared to a core module's flake.nix (from Part 1), this is much simpler:

Core module (calc_module) QML UI (calc_ui)
Builder logos-module-builder.lib.mkLogosModule Plain stdenv.mkDerivation
Compilation CMake → C++ → .so plugin No compilation — file copy
Dependencies Qt, Logos SDK, CMake, C compiler None
Build time Minutes (first build) Seconds

Step 5: Build the Plugin

5.1 Initialize the Git repo

Nix flakes require a git repository:

cd logos-calc-ui
git init
git add -A
git commit -m "Initial commit"

5.2 Build with Nix

nix build

This completes in seconds since there's no compilation — it just copies files.

5.3 Inspect the output

ls -la result/lib/
Main.qml
metadata.json
icons/

That's it. A QML plugin is just its source files packaged for installation.


Step 6: Run in logos-app

6.1 Build logos-app

nix build 'github:logos-co/logos-app#app' --out-link ./logos-app

6.2 Set up the modules directory

You need both the core module (from Part 1) and the QML UI plugin. Use the LGX bundler and package manager to install them:

# Bundle and install the core 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

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

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

6.3 Launch

./logos-app/bin/logos-app \
  --modules-dir ./modules \
  --ui-plugins-dir ./ui-plugins

You should see:

  1. The logos-app window opens with a sidebar
  2. "Calculator UI" appears as a tab (with your icon if you provided one)
  3. Click the tab to see your QML interface
  4. Enter numbers and click Add, Multiply, Factorial, or Fibonacci
  5. The result appears in the green result box

6.4 What happens when you click "Add"

Here's the full chain when you enter 3 and 5 and click Add:

1. QML: Button.onClicked → callTwoOp("add", "3", "5")
2. QML: callTwoOp() → callModule("add", [3, 5])
3. QML: logos.callModule("calc_module", "add", [3, 5])
4. logos-app: Routes call via IPC to logos_host process
5. logos_host: QMetaObject::invokeMethod(plugin, "add", 3, 5)
6. C++: CalcModulePlugin::add(3, 5) → calc_add(3, 5)
7. C: Returns 8
8. Back through the chain → QML: root.result = "8"
9. QML: Result box displays "8"

Step 7: Development Workflow

7.1 Live reloading

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

# Point QML_UI at your source directory
QML_UI=$(pwd) ./logos-app/bin/logos-app \
  --modules-dir ./modules \
  --ui-plugins-dir ./ui-plugins

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

7.2 Debugging

Since QML plugins are sandboxed, you can't use console.log() to write to the terminal in production. But in development mode, console.log() output appears in the terminal where you launched logos-app.

Add debug logging to your bridge function:

function callModule(method, args) {
    console.log("callModule:", method, JSON.stringify(args))
    // ...
    var res = logos.callModule("calc_module", method, args)
    console.log("result:", res)
    root.result = String(res)
}

7.3 Testing without logos-app

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 6.2 are local packages — they work on the machine that built them but contain /nix/store references. 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-app (via nix build '.#app') 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-app README for details.


Recap: Core Module vs. QML UI Plugin

Core Module (Part 1) QML UI Plugin (Part 2)
Language C++ (wrapping C) QML (JavaScript + declarative UI)
Files .cpp, .h, CMakeLists.txt, module.yaml Main.qml only
Compilation Yes (CMake → shared library) No (file copy)
metadata type "core" "ui_qml"
metadata main "calc_module_plugin" (binary) "Main.qml" (source file)
Runs in logos_host process Sandboxed QML engine
Network access Yes No (sandboxed)
Filesystem access Yes Own directory only (sandboxed)
Calls other modules Via LogosAPI* (C++) Via logos.callModule() (JS)
Nix builder mkLogosModule Plain mkDerivation
Build time Minutes (first build) Seconds

What's Next

You now have a complete two-layer Logos application:

libcalc (C library)
  └── calc_module (Logos core module, wraps libcalc)
        └── calc_ui (QML UI, calls calc_module via bridge)

From here you could:

  • Add more methods to calc_module and expose them in the UI
  • Call multiple modules — a QML UI can call any loaded core module, not just one
  • Use the Logos Design System — the logos-design-system QML library provides styled components for a consistent look
  • Add inter-module events — core modules can emit events via eventResponse signals, and QML UIs can listen for them
  • Build a C++ UI module — for cases where QML sandboxing is too restrictive, you can build a native Qt widget plugin (see the Developer Guide, Section 7.2)