# 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](tutorial-wrapping-c-library.md), 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-basecamp` **Prerequisites:** - Completed [Part 1](tutorial-wrapping-c-library.md) — 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-basecamp | | 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 ```bash 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-basecamp` sidebar. If you don't have one, the app will use a default icon. --- ## Step 2: Write `metadata.json` This tells `logos-basecamp` what your plugin is and how to load it. ```json { "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-basecamp` 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): ```json { "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`: ```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`: ```qml 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-basecamp)" 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-basecamp` 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: ```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 } // ── 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-basecamp)" 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. ```nix { 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: ```bash cd logos-calc-ui git init git add -A git commit -m "Initial commit" ``` ### 5.2 Build with Nix ```bash nix build ``` This completes in seconds since there's no compilation — it just copies files. ### 5.3 Inspect the output ```bash 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-basecamp` ### 6.1 Build `logos-basecamp` ```bash nix build 'github:logos-co/logos-basecamp#app' --out-link ./logos-basecamp ``` ### 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: ```bash # 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 ```bash ./logos-basecamp/bin/logos-basecamp \ --modules-dir ./modules \ --ui-plugins-dir ./ui-plugins ``` You should see: 1. The `logos-basecamp` 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-basecamp: 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: ```bash # Point QML_UI at your source directory QML_UI=$(pwd) ./logos-basecamp/bin/logos-basecamp \ --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-basecamp`. Add debug logging to your bridge function: ```qml 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-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. ```bash # 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: ```bash # 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: ```bash 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 '.#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-basecamp README](https://github.com/logos-co/logos-basecamp/blob/master/README.md) 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](https://github.com/logos-co/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](logos-developer-guide.md), Section 7.2)