# Tutorial Part 2: Building a QML UI for Your Logos Module 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'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](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) --- ## 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 Create a new directory and initialise it from the QML module template: `mkdir logos-calc-ui && cd logos-calc-ui` ```bash nix flake init -t github:logos-co/logos-module-builder#ui-qml ``` > **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: ``` 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: ```json { "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: ```bash 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., the input must be called `calc_module`. The URL can point anywhere (a local `path:` or a remote `github:` repo); the attribute name is how the builder resolves dependencies. --- ## Step 3: Write `Main.qml` 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()` ```qml 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, and the QML receives it through the `logos.onModuleEvent()` subscription set up in `Component.onCompleted`. On the `calc_module` side (Part 1), that event is just the `versionReady(...)` method declared in its `logos_events:` block — the module's `libVersionNotify()` calls it. Nothing about the QML changes regardless of how the backend module is written; the bridge only sees the event name and its arguments. The `logos` object is injected by the host at runtime. --- ## Step 4: Update `flake.nix` The template already has everything wired up. Update the description and add `calc_module` as a dependency input: ```nix { description = "Calculator QML UI Plugin for Logos - frontend for calc_module"; inputs = { logos-module-builder.url = "github:logos-co/logos-module-builder"; # Points at your local calc_module checkout. This is a placeholder — # you lock it to your actual path in the next step with # `nix flake update --override-input` (see "Test with nix run" below). calc_module.url = "path:/path/to/your/calc_module"; }; outputs = inputs@{ logos-module-builder, ... }: logos-module-builder.lib.mkLogosQmlModule { src = ./.; configFile = ./metadata.json; flakeInputs = inputs; }; } ``` The input attribute name (`calc_module`) must match the dependency name in `metadata.json`. The placeholder `path:/path/to/your/calc_module` is **not** meant to be edited by hand — Nix won't let a `flake.nix` input use a relative path like `../logos-calc-module` (the flake is evaluated from a sandboxed copy, so `..` escapes it). Instead you point it at your real checkout **once** via `--override-input` in the next step, which records the resolved absolute path in `flake.lock`. After that, plain `nix run` uses the locked path with no override needed. - **`path:`** (used here) — a local directory on disk. Best for developing `calc_module` and its UI side by side, no network. - **`github:`** — fetches `calc_module` from a remote repo instead (for CI, or once it's published to its own repo), e.g. `calc_module.url = "github:your-org/your-calc-module";`. > **Important:** Whichever URL scheme you use, `calc_module` must be built with its shared library (`.so` on Linux, `.dylib` on macOS) present in `lib/`. If the library is missing, the nix build will fail with linker errors. See [Part 1, Step 1.5](tutorial-wrapping-c-library.md#15-build-the-shared-library) for build instructions. `mkLogosQmlModule` handles everything — it stages QML files, metadata, and icons into a plugin directory, bundles all module dependencies (direct and transitive) from their LGX packages, and automatically wires up `apps.default` so `nix run .` launches the UI in a standalone window with all required backend modules self-contained. `flakeInputs = inputs` passes all inputs so that dependencies declared in `metadata.json` are resolved automatically. --- ## Step 5: Test with `nix run` ### 5.1 UI only (layout preview) ```bash git add -A ``` ```bash nix flake update --override-input calc_module path:../logos-calc-module ``` ```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. --- ## 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. ### 6.1 Ensure `calc_module` is built Go back to your `logos-calc-module` directory and verify the shared library exists: ```bash 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](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 ``` Also make sure the module itself builds successfully: ```bash 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. ### 6.2 Option A: Use `--override-input` (quick, no flake.nix edits) You can point `calc_module` at your local checkout for a single command, without touching `flake.nix` or its lock — handy for a one-off run or when the input is set to a `github:` URL: ```bash 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. ### 6.3 Option B: Lock `path:` once, then run normally If you're iterating on both repos side by side, lock `calc_module` to your local checkout once. You can't write `path:../logos-calc-module` directly into `flake.nix` — Nix evaluates the flake from a sandboxed copy, so a relative `..` escapes it and is rejected. Instead, lock it with an `--override-input` (which resolves to an absolute path and stores it in `flake.lock`): ```bash nix flake update --override-input calc_module path:../logos-calc-module git add flake.lock ``` After that, plain `nix run .` uses the locked local path — no override needed on each command: ```bash nix run . ``` Re-run the `nix flake update --override-input …` line whenever you want to re-point or refresh the lock. Switch to a `github:` URL in `flake.nix` when you're ready to pin to a published version. ### 6.4 Option C: Pin to the remote repo Once `calc_module` is published to its own repo (with the `.so`/`.dylib` committed in `lib/`), point the input at it with a `github:` URL instead of the local `path:` — e.g. `calc_module.url = "github:your-org/your-calc-module";`. Then a plain `nix run .` fetches and builds `calc_module` from the remote: ```bash nix run . ``` > **Important:** The remote repo must contain the built `.so`/`.dylib` in `lib/` (or the nix build must produce it). If the shared library is missing, the `calc_module` build will fail with linker errors. Whichever option you choose, clicking **Add**, **Multiply**, **Factorial**, or **Fibonacci** now calls the real module. --- ## Step 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. ```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 8: Load in `logos-basecamp` ### 8.1 Bundle as LGX packages Create `.lgx` packages for both dev and portable variants. Use `--out-link` to avoid overwriting the `result` symlink: ```bash # 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 — Bundling with nix-bundle-lgx](logos-developer-guide.md#32-bundling-with-nix-bundle-lgx). ```bash nix build '.#lgx' --out-link result-lgx nix build '.#lgx-portable' --out-link result-lgx-portable ``` > **Re-locking note:** earlier steps rebuilt `calc_module` and dropped `result-lgx` links inside its directory, so its on-disk contents changed since you first locked it. Because `calc_module` is a local `path:` input, re-run `nix flake update --override-input calc_module path:../logos-calc-module` before building so the lock matches the current contents (a stricter Nix otherwise rejects the stale hash). ### 8.2 Build logos-basecamp Build the basecamp desktop shell: ```bash nix build 'github:logos-co/logos-basecamp' -o basecamp-result ``` Basecamp manages its own per-user data directory and preinstalls its bundled modules (`main_ui`, `package_manager`, …) from the build. It does **not** accept `--modules-dir` / `--ui-plugins-dir` flags; instead you point it at a data directory with `--user-dir` (or the `LOGOS_USER_DIR` env var), and it reads installed core modules from `