fix scaffold of UI apps

This commit is contained in:
Iuri Matias
2026-04-07 10:26:04 -04:00
parent 75cc275a85
commit 0fa56cc246
5 changed files with 814 additions and 144 deletions
+5
View File
@@ -73,6 +73,11 @@
type = "app";
program = "${self.packages.${system}.default}/bin/logos-dev-boost";
};
# Alias for `nix run .#app` (same as default)
app = {
type = "app";
program = "${self.packages.${system}.default}/bin/logos-dev-boost";
};
mcp-server = {
type = "app";
program = "${self.packages.${system}.default}/bin/logos-dev-boost-mcp";
+138 -42
View File
@@ -2,15 +2,28 @@
## IComponent Pattern
UI Apps are Qt plugins loaded directly by Basecamp. They implement `IComponent`:
UI apps are Qt plugins loaded directly by Basecamp. They implement `IComponent`, which is **vendored locally** in every project (not provided by the SDK):
`interfaces/IComponent.h`:
```cpp
#pragma once
#include <QObject>
#include <QWidget>
#include <QtPlugin>
class LogosAPI;
class IComponent {
public:
virtual ~IComponent() = default;
virtual QWidget* createWidget(LogosAPI* logosAPI = nullptr) = 0;
virtual void destroyWidget(QWidget* widget) = 0;
};
#define IComponent_iid "com.logos.component.IComponent"
Q_DECLARE_INTERFACE(IComponent, IComponent_iid)
```
The plugin class inherits both `QObject` and `IComponent`, and uses `Q_PLUGIN_METADATA`:
@@ -19,86 +32,162 @@ The plugin class inherits both `QObject` and `IComponent`, and uses `Q_PLUGIN_ME
class MyPlugin : public QObject, public IComponent {
Q_OBJECT
Q_INTERFACES(IComponent)
Q_PLUGIN_METADATA(IID IComponent_iid FILE "metadata.json")
Q_PLUGIN_METADATA(IID IComponent_iid FILE "../metadata.json")
public:
QWidget* createWidget(LogosAPI* logosAPI = nullptr) override;
void destroyWidget(QWidget* widget) override;
};
```
## CMakeLists.txt Requirements
```cmake
set(CMAKE_AUTOMOC ON) # not AUTORCC
logos_module(
NAME my_app
SOURCES ...
INCLUDE_DIRS
${CMAKE_CURRENT_SOURCE_DIR}/interfaces # required for #include <IComponent.h>
)
find_package(Qt6 REQUIRED COMPONENTS Widgets Quick QuickWidgets QuickControls2)
qt_add_resources(my_app_module_plugin ui_qml_resources
PREFIX "/"
FILES src/qml/Main.qml
)
```
QML is embedded via `qt_add_resources` (no `.qrc` file needed). The embedded path is `qrc:/src/qml/Main.qml`.
## C++/QML Boundary Rules
This is the most important convention for UI apps. Every piece of logic must go in the right layer:
Every piece of logic must go in the right layer:
| Concern | C++ (backend class) | QML |
|---------|---------------------|-----|
| Data models, state | `Q_PROPERTY` on `QObject` | Bind to `backend.property` |
| Business logic | Methods on backend class | Never — no JS business logic |
| Module calls | `LogosAPI::callModule()` | `logos.callModule()` (thin wrapper) |
| File I/O, networking | Always C++ | Never |
| UI layout, styling | Never | Always use `Logos.Theme`, `Logos.Controls` |
| User interactions | `Q_INVOKABLE` slots | `onClicked: backend.doThing()` |
| Plugin lifecycle | `IComponent::createWidget/destroyWidget` | N/A |
| Concern | C++ (backend class) | QML |
| -------------------- | ---------------------------------------- | -------------------------------------- |
| Data models, state | `Q_PROPERTY` on `QObject` | Bind to `backend.property` |
| Business logic | Methods on backend class | Never — no JS business logic |
| Module calls | Via `LogosAPI*` | Never |
| File I/O, networking | Always C++ | Never |
| UI layout, styling | Never | QML; use `Logos.Theme` inside Basecamp |
| User interactions | `Q_INVOKABLE` slots | `onClicked: backend.doThing()` |
| Plugin lifecycle | `IComponent::createWidget/destroyWidget` | N/A |
## Plugin `createWidget()` Pattern
```cpp
QWidget* MyPlugin::createWidget(LogosAPI* logosAPI) {
QQuickStyle::setStyle("Basic"); // consistent cross-platform rendering
auto* quickWidget = new QQuickWidget();
quickWidget->setMinimumSize(800, 600);
quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView);
auto* backend = new MyBackend(logosAPI, quickWidget);
quickWidget->rootContext()->setContextProperty("backend", backend);
// Dev mode: export QML_PATH=$PWD/src/qml to load Main.qml from disk
const QString devSource = QString::fromUtf8(qgetenv("QML_PATH"));
const QUrl qmlUrl = devSource.isEmpty()
? QUrl("qrc:/src/qml/Main.qml")
: QUrl::fromLocalFile(QDir(devSource).filePath("Main.qml"));
quickWidget->setSource(qmlUrl);
if (quickWidget->status() == QQuickWidget::Error) {
qWarning() << "MyPlugin: failed to load QML from" << qmlUrl;
for (const auto& e : quickWidget->errors())
qWarning() << e.toString();
}
return quickWidget;
}
```
## Backend Class Pattern
The backend is a `QObject` subclass exposed to QML as a context property:
```cpp
class MyBackend : public QObject {
Q_OBJECT
Q_PROPERTY(QVariantList items READ items NOTIFY itemsChanged)
Q_PROPERTY(QVariantList items READ items NOTIFY itemsChanged)
Q_PROPERTY(int itemCount READ itemCount NOTIFY itemCountChanged)
Q_PROPERTY(QString statusMessage READ statusMessage NOTIFY statusMessageChanged)
public:
explicit MyBackend(LogosAPI* api, QObject* parent = nullptr);
QVariantList items() const;
Q_INVOKABLE void addItem(const QString& name);
QVariantList items() const;
int itemCount() const;
QString statusMessage() const;
Q_INVOKABLE void addItem(const QString& text);
Q_INVOKABLE void removeItem(int index);
signals:
void itemsChanged();
void itemCountChanged();
void statusMessageChanged();
private:
LogosAPI* m_api;
QVariantList m_items;
QString m_statusMessage;
};
```
In `createWidget()`, set the backend as a context property on the QML engine:
```cpp
QWidget* MyPlugin::createWidget(LogosAPI* logosAPI) {
auto* widget = new QQuickWidget;
auto* backend = new MyBackend(logosAPI, widget);
widget->rootContext()->setContextProperty("backend", backend);
widget->setSource(QUrl("qrc:/qml/Main.qml"));
return widget;
}
```
- Expose every QML-bound value with a `Q_PROPERTY` + NOTIFY signal
- `statusMessage` is useful for feedback displayed in a status bar
- Actions go in `Q_INVOKABLE` methods
- Call other modules via `LogosAPI*` inside the backend
## QML Conventions
- Entry point is always `Main.qml`
- Use `Logos.Theme` for all colors: `Logos.Theme.backgroundColor`, `Logos.Theme.textColor`
- Use `Logos.Controls` for interactive elements: `LogosButton`, `LogosText`
- Never hardcode colors — always use theme properties
- Access the backend via the `backend` context property
- Use declarative bindings over imperative JavaScript
- Root element should use `anchors.fill: parent`
- Entry point is always `src/qml/Main.qml`, embedded at `qrc:/src/qml/Main.qml`
- Root element: `Rectangle { anchors.fill: parent }`
- React to backend signals via `Connections { target: backend }`
- Inside Basecamp: use `Logos.Theme` for colors, `Logos.Controls` for components
- When running standalone (`nix run .`): use plain QtQuick; `Logos.Theme` is not available
- Never hardcode UI logic in JavaScript
## Dev Mode
```bash
export QML_PATH=$PWD/src/qml
nix run . # loads Main.qml from disk; restart app to pick up QML changes
```
C++ changes always require `nix build`.
## Standalone Test
```bash
nix build
nix run . # or: nix run .#app
```
`mkLogosModule` with `"type": "ui"` automatically wires up `apps.default` — no manual flake setup needed.
## Calling Logos Modules
From C++ backend:
```cpp
QVariant result = m_api->callModule("storage", "save", {key, value});
auto* client = m_logosAPI->getClient("storage_module");
QVariant result = client->invokeRemoteMethod("storage_module", "save", key, value);
```
From QML (via LogosQmlBridge):
```qml
logos.callModule("storage", "save", [key, value])
Or with generated typed wrappers (recommended when available):
```cpp
#include "logos_sdk.h" // generated at build time from metadata.json dependencies
LogosModules logos(m_logosAPI);
logos.storage_module.save(key, value);
```
Always declare module dependencies in `metadata.json` so they are loaded before the UI app.
Always declare module dependencies in `metadata.json` `"dependencies"` so they are loaded before the UI app.
## metadata.json for UI Apps
@@ -108,8 +197,15 @@ Always declare module dependencies in `metadata.json` so they are loaded before
"type": "ui",
"version": "1.0.0",
"description": "My UI application",
"icon": "icon.png",
"icon": "icons/my_app.png",
"category": "tools",
"dependencies": ["storage_module"]
"main": "my_app_plugin",
"dependencies": ["storage_module"],
"nix": {
"packages": { "build": [], "runtime": ["qt6.qtdeclarative"] },
"external_libraries": [],
"cmake": { "find_packages": [], "extra_sources": [] }
}
}
```
+446 -35
View File
@@ -270,6 +270,11 @@ function createUiApp(
filesCreated: string[]
) {
const pascal = toPascalCase(name);
const qmlEscDescription = description
.replace(/\r?\n/g, " ")
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"');
const flakeEscDescription = description.replace(/"/g, '\\"').replace(/\r?\n/g, " ");
writeFile(
path.join(dir, "metadata.json"),
@@ -279,9 +284,17 @@ function createUiApp(
version: "1.0.0",
description,
type: "ui",
category: "tools",
category: "ui",
main: `${name}_plugin`,
dependencies: [],
nix: {
packages: {
build: [],
runtime: ["qt6.qtdeclarative"],
},
external_libraries: [],
cmake: { find_packages: [], extra_sources: [] },
},
},
null,
2
@@ -290,19 +303,56 @@ function createUiApp(
);
writeFile(
path.join(dir, `src/${pascal}Plugin.h`),
path.join(dir, "interfaces/IComponent.h"),
`#pragma once
#include <QObject>
#include <QWidget>
#include <QtPlugin>
#include "IComponent.h"
class LogosAPI;
class IComponent {
public:
virtual ~IComponent() = default;
virtual QWidget* createWidget(LogosAPI* logosAPI = nullptr) = 0;
virtual void destroyWidget(QWidget* widget) = 0;
};
#define IComponent_iid "com.logos.component.IComponent"
Q_DECLARE_INTERFACE(IComponent, IComponent_iid)
`,
filesCreated
);
writeFile(
path.join(dir, ".gitignore"),
`.DS_Store
result
build/
`,
filesCreated
);
writeFile(
path.join(dir, `src/${name}_plugin.h`),
`#pragma once
#include <IComponent.h>
#include <QObject>
class LogosAPI;
class ${pascal}Plugin : public QObject, public IComponent {
Q_OBJECT
Q_INTERFACES(IComponent)
Q_PLUGIN_METADATA(IID IComponent_iid FILE "metadata.json")
Q_PLUGIN_METADATA(IID IComponent_iid FILE "../metadata.json")
public:
QWidget* createWidget(LogosAPI* logosAPI = nullptr) override;
explicit ${pascal}Plugin(QObject* parent = nullptr);
~${pascal}Plugin();
Q_INVOKABLE QWidget* createWidget(LogosAPI* logosAPI = nullptr) override;
void destroyWidget(QWidget* widget) override;
};
`,
@@ -310,19 +360,48 @@ public:
);
writeFile(
path.join(dir, `src/${pascal}Plugin.cpp`),
`#include "${pascal}Plugin.h"
path.join(dir, `src/${name}_plugin.cpp`),
`#include "${name}_plugin.h"
#include "${pascal}Backend.h"
#include <QDebug>
#include <QDir>
#include <QString>
#include <QtGlobal>
#include <QQuickWidget>
#include <QQmlContext>
#include <QQuickStyle>
#include <QUrl>
${pascal}Plugin::${pascal}Plugin(QObject* parent) : QObject(parent) {}
${pascal}Plugin::~${pascal}Plugin() {}
QWidget* ${pascal}Plugin::createWidget(LogosAPI* logosAPI) {
auto* widget = new QQuickWidget;
auto* backend = new ${pascal}Backend(logosAPI, widget);
widget->rootContext()->setContextProperty("backend", backend);
widget->setSource(QUrl("qrc:/qml/Main.qml"));
widget->setResizeMode(QQuickWidget::SizeRootObjectToView);
return widget;
QQuickStyle::setStyle("Basic");
auto* quickWidget = new QQuickWidget();
quickWidget->setMinimumSize(800, 600);
quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView);
auto* backend = new ${pascal}Backend(logosAPI, quickWidget);
quickWidget->rootContext()->setContextProperty("backend", backend);
// Dev mode: set QML_PATH to the directory containing Main.qml to load from disk without rebuilding.
// Example: export QML_PATH=$PWD/src/qml
const QString devSource = QString::fromUtf8(qgetenv("QML_PATH"));
const QUrl qmlUrl = devSource.isEmpty()
? QUrl("qrc:/src/qml/Main.qml")
: QUrl::fromLocalFile(QDir(devSource).filePath("Main.qml"));
quickWidget->setSource(qmlUrl);
if (quickWidget->status() == QQuickWidget::Error) {
qWarning() << "${pascal}Plugin: failed to load QML from" << qmlUrl;
for (const auto& e : quickWidget->errors()) {
qWarning() << e.toString();
}
}
return quickWidget;
}
void ${pascal}Plugin::destroyWidget(QWidget* widget) {
@@ -335,7 +414,9 @@ void ${pascal}Plugin::destroyWidget(QWidget* widget) {
writeFile(
path.join(dir, `src/${pascal}Backend.h`),
`#pragma once
#include <QObject>
#include <QString>
#include <QVariantList>
class LogosAPI;
@@ -343,19 +424,34 @@ class LogosAPI;
class ${pascal}Backend : public QObject {
Q_OBJECT
Q_PROPERTY(QVariantList items READ items NOTIFY itemsChanged)
Q_PROPERTY(QString statusMessage READ statusMessage NOTIFY statusMessageChanged)
Q_PROPERTY(int itemCount READ itemCount NOTIFY itemCountChanged)
public:
explicit ${pascal}Backend(LogosAPI* api, QObject* parent = nullptr);
QVariantList items() const;
Q_INVOKABLE void addItem(const QString& name);
QVariantList items() const;
QString statusMessage() const;
int itemCount() const;
Q_INVOKABLE void addNote(const QString& text);
Q_INVOKABLE void removeItem(int index);
Q_INVOKABLE void clearAll();
signals:
void itemsChanged();
void statusMessageChanged();
void itemCountChanged();
void noteAdded(int index, const QString& text);
void noteRemoved(int index);
private:
void setStatusMessage(const QString& message);
void bumpCounts();
LogosAPI* m_api;
QVariantList m_items;
QString m_statusMessage;
};
`,
filesCreated
@@ -365,23 +461,80 @@ private:
path.join(dir, `src/${pascal}Backend.cpp`),
`#include "${pascal}Backend.h"
#include <QDateTime>
${pascal}Backend::${pascal}Backend(LogosAPI* api, QObject* parent)
: QObject(parent), m_api(api) {}
: QObject(parent)
, m_api(api)
{
Q_UNUSED(m_api);
setStatusMessage("Ready. Add a note below.");
}
QVariantList ${pascal}Backend::items() const { return m_items; }
QVariantList ${pascal}Backend::items() const {
return m_items;
}
void ${pascal}Backend::addItem(const QString& name) {
QVariantMap item;
item["name"] = name;
m_items.append(item);
QString ${pascal}Backend::statusMessage() const {
return m_statusMessage;
}
int ${pascal}Backend::itemCount() const {
return m_items.size();
}
void ${pascal}Backend::setStatusMessage(const QString& message) {
if (m_statusMessage == message) {
return;
}
m_statusMessage = message;
emit statusMessageChanged();
}
void ${pascal}Backend::bumpCounts() {
emit itemsChanged();
emit itemCountChanged();
}
void ${pascal}Backend::addNote(const QString& text) {
const QString trimmed = text.trimmed();
if (trimmed.isEmpty()) {
setStatusMessage("Enter some text before adding a note.");
return;
}
QVariantMap row;
row["title"] = trimmed;
row["created"] = QDateTime::currentDateTime().toString(Qt::ISODate);
const int index = m_items.size();
m_items.append(row);
bumpCounts();
emit noteAdded(index, trimmed);
setStatusMessage(QString("Added note (%1 total).").arg(m_items.size()));
}
void ${pascal}Backend::removeItem(int index) {
if (index >= 0 && index < m_items.size()) {
m_items.removeAt(index);
emit itemsChanged();
if (index < 0 || index >= m_items.size()) {
setStatusMessage("Invalid note index.");
return;
}
m_items.removeAt(index);
bumpCounts();
emit noteRemoved(index);
setStatusMessage(m_items.isEmpty() ? "All notes cleared." : QString("Removed note (%1 left).").arg(m_items.size()));
}
void ${pascal}Backend::clearAll() {
if (m_items.isEmpty()) {
setStatusMessage("Nothing to clear.");
return;
}
m_items.clear();
bumpCounts();
setStatusMessage("Cleared all notes.");
}
`,
filesCreated
@@ -394,33 +547,291 @@ import QtQuick.Controls
import QtQuick.Layouts
Rectangle {
anchors.fill: parent
id: root
color: "#1e1e1e"
Connections {
target: backend
function onNoteAdded(index, text) {
noteField.clear()
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 12
anchors.margins: 24
spacing: 16
Text {
text: "${pascal}"
font.pixelSize: 24
font.bold: true
color: "#ffffff"
}
ListView {
Text {
text: "${qmlEscDescription}"
font.pixelSize: 14
color: "#a0a0a0"
wrapMode: Text.Wrap
Layout.fillWidth: true
Layout.fillHeight: true
model: backend.items
delegate: Text {
text: modelData.name
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
color: "#2d2d2d"
radius: 4
border.color: "#444444"
border.width: 1
TextField {
id: noteField
anchors.fill: parent
anchors.margins: 4
placeholderText: "Write a note…"
color: "#ffffff"
selectionColor: "#4A90E2"
background: Rectangle { color: "transparent" }
}
}
Button {
text: "Add Item"
onClicked: backend.addItem("New Item")
RowLayout {
spacing: 10
Button {
text: "Add note"
onClicked: backend.addNote(noteField.text)
contentItem: Text {
text: parent.text
font.pixelSize: 13
color: parent.enabled ? "#ffffff" : "#808080"
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
implicitWidth: 110
implicitHeight: 32
color: parent.enabled ? (parent.pressed ? "#1a7f37" : "#238636") : "#2d2d2d"
radius: 4
border.color: parent.enabled ? "#2ea043" : "#3d3d3d"
border.width: 1
}
}
Button {
text: "Clear all"
enabled: backend.itemCount > 0
onClicked: backend.clearAll()
contentItem: Text {
text: parent.text
font.pixelSize: 13
color: parent.enabled ? "#ffffff" : "#808080"
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
implicitWidth: 100
implicitHeight: 32
color: parent.enabled ? (parent.pressed ? "#5c1a1a" : "#7a2a2a") : "#2d2d2d"
radius: 4
border.color: parent.enabled ? "#c62828" : "#3d3d3d"
border.width: 1
}
}
Item { Layout.fillWidth: true }
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: "#252526"
radius: 6
border.color: "#333333"
border.width: 1
Text {
anchors.centerIn: parent
visible: backend.itemCount === 0
text: "No notes yet.\\nAdd one with the field above."
horizontalAlignment: Text.AlignHCenter
color: "#808080"
font.pixelSize: 14
}
ListView {
id: noteList
anchors.fill: parent
anchors.margins: 8
clip: true
visible: backend.itemCount > 0
model: backend.items
spacing: 6
delegate: Rectangle {
width: ListView.view.width
height: 56
color: index % 2 === 0 ? "#2d2d2d" : "#333333"
radius: 4
border.color: mouseArea.containsMouse ? "#4A90E2" : "#444444"
border.width: 1
RowLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 12
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text {
text: modelData.title
color: "#ffffff"
font.pixelSize: 14
elide: Text.ElideRight
Layout.fillWidth: true
}
Text {
text: modelData.created || ""
color: "#a0a0a0"
font.pixelSize: 11
}
}
Button {
text: "Delete"
onClicked: backend.removeItem(index)
contentItem: Text {
text: parent.text
font.pixelSize: 12
color: parent.enabled ? "#ffffff" : "#808080"
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
implicitWidth: 72
implicitHeight: 28
color: parent.pressed ? "#5c1a1a" : "#7a2a2a"
radius: 4
border.color: "#c62828"
border.width: 1
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
}
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
color: "#2d2d2d"
radius: 4
border.color: "#444444"
border.width: 1
Text {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 10
text: backend.statusMessage
color: "#c0c0c0"
font.pixelSize: 12
elide: Text.ElideRight
}
}
}
}
`,
filesCreated
);
writeFile(
path.join(dir, "CMakeLists.txt"),
`cmake_minimum_required(VERSION 3.14)
project(${pascal}Plugin LANGUAGES CXX)
set(CMAKE_AUTOMOC ON)
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
else()
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
endif()
logos_module(
NAME ${name}
SOURCES
src/${name}_plugin.h
src/${name}_plugin.cpp
src/${pascal}Backend.h
src/${pascal}Backend.cpp
INCLUDE_DIRS
\${CMAKE_CURRENT_SOURCE_DIR}/interfaces
)
find_package(Qt6 REQUIRED COMPONENTS Widgets Quick QuickWidgets QuickControls2)
qt_add_resources(${name}_module_plugin ui_qml_resources
PREFIX "/"
FILES
src/qml/Main.qml
)
target_link_libraries(${name}_module_plugin PRIVATE
Qt6::Widgets
Qt6::Quick
Qt6::QuickWidgets
Qt6::QuickControls2
)
`,
filesCreated
);
writeFile(
path.join(dir, "flake.nix"),
`{
description = "${flakeEscDescription}";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
nix-bundle-lgx.url = "github:logos-co/nix-bundle-lgx";
};
outputs = inputs@{ logos-module-builder, ... }:
let
base = logos-module-builder.lib.mkLogosModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
in
base // (
if base ? apps then {
apps = builtins.mapAttrs (_system: apps:
apps // { app = apps.default; }
) base.apps;
} else {}
);
}
`,
filesCreated
);
+205 -55
View File
@@ -1,35 +1,77 @@
---
name: create-ui-app
description: Activate when creating a Logos Basecamp UI app with IComponent, C++ backend, and QML frontend. Covers the plugin class, QObject backend, QML entry point, and the C++/QML boundary.
---
## name: create-ui-app
description: Activate when creating a Logos Basecamp UI app with IComponent, C++backend, and QML frontend. Covers the plugin class, QObject backend, QML entry point, and the C++/QML boundary.
# Create a UI App for Logos Basecamp
## When to Use
Use this skill when:
- Creating an application with a graphical interface for Basecamp
- Building an `IComponent` plugin with `createWidget` / `destroyWidget`
- The app has a C++ backend and QML frontend
## Step 1: Create Project Structure
## Fastest path: scaffold with logos-dev-boost
```bash
nix run github:logos-co/logos-dev-boost -- init my_app --type ui-app
cd logos-my-app
git init && git add -A
nix build
nix run . # standalone app (same as nix run .#app)
```
This generates the full structure below, compiles cleanly, and runs standalone via `nix run`.
---
## Step 1: Project Structure
```
my_app/
logos-my-app/
├── interfaces/
│ └── IComponent.h ← vendored locally (every UI repo does this)
├── src/
│ ├── MyAppPlugin.h
│ ├── MyAppPlugin.cpp
│ ├── my_app_plugin.h
│ ├── my_app_plugin.cpp
│ ├── MyAppBackend.h
│ ├── MyAppBackend.cpp
│ └── qml/
── Main.qml
│ └── resources.qrc
── Main.qml ← QML entry point (embedded via qt_add_resources)
├── metadata.json
├── CMakeLists.txt
── flake.nix
── flake.nix
└── .gitignore
```
## Step 2: Create metadata.json
`interfaces/IComponent.h` must be present — it is **not** provided by the SDK, every UI repo vendors it locally.
## Step 2: `interfaces/IComponent.h`
```cpp
#pragma once
#include <QObject>
#include <QWidget>
#include <QtPlugin>
class LogosAPI;
class IComponent {
public:
virtual ~IComponent() = default;
virtual QWidget* createWidget(LogosAPI* logosAPI = nullptr) = 0;
virtual void destroyWidget(QWidget* widget) = 0;
};
#define IComponent_iid "com.logos.component.IComponent"
Q_DECLARE_INTERFACE(IComponent, IComponent_iid)
```
## Step 3: `metadata.json`
```json
{
@@ -39,48 +81,131 @@ my_app/
"type": "ui",
"category": "tools",
"main": "my_app_plugin",
"dependencies": []
"dependencies": [],
"nix": {
"packages": { "build": [], "runtime": ["qt6.qtdeclarative"] },
"external_libraries": [],
"cmake": { "find_packages": [], "extra_sources": [] }
}
}
```
Note: UI apps do NOT use `"interface": "universal"`. They are hand-written Qt plugins.
## Step 3: Create the Plugin Class
## Step 4: `CMakeLists.txt`
`src/MyAppPlugin.h`:
```cmake
cmake_minimum_required(VERSION 3.14)
project(MyAppPlugin LANGUAGES CXX)
set(CMAKE_AUTOMOC ON)
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
else()
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
endif()
logos_module(
NAME my_app
SOURCES
src/my_app_plugin.h
src/my_app_plugin.cpp
src/MyAppBackend.h
src/MyAppBackend.cpp
INCLUDE_DIRS
${CMAKE_CURRENT_SOURCE_DIR}/interfaces # so #include <IComponent.h> resolves
)
find_package(Qt6 REQUIRED COMPONENTS Widgets Quick QuickWidgets QuickControls2)
qt_add_resources(my_app_module_plugin ui_qml_resources
PREFIX "/"
FILES
src/qml/Main.qml
)
target_link_libraries(my_app_module_plugin PRIVATE
Qt6::Widgets
Qt6::Quick
Qt6::QuickWidgets
Qt6::QuickControls2
)
```
Key points:
- `CMAKE_AUTOMOC ON` (not AUTORCC — resources go through `qt_add_resources`)
- `INCLUDE_DIRS` points at `interfaces/` so `<IComponent.h>` resolves
- `qt_add_resources` embeds QML at `qrc:/src/qml/Main.qml`
## Step 5: Plugin Class
`src/my_app_plugin.h`:
```cpp
#pragma once
#include <IComponent.h>
#include <QObject>
#include <QWidget>
#include <QtPlugin>
#include "IComponent.h"
class LogosAPI;
class MyAppPlugin : public QObject, public IComponent {
Q_OBJECT
Q_INTERFACES(IComponent)
Q_PLUGIN_METADATA(IID IComponent_iid FILE "metadata.json")
Q_PLUGIN_METADATA(IID IComponent_iid FILE "../metadata.json")
public:
QWidget* createWidget(LogosAPI* logosAPI = nullptr) override;
explicit MyAppPlugin(QObject* parent = nullptr);
~MyAppPlugin();
Q_INVOKABLE QWidget* createWidget(LogosAPI* logosAPI = nullptr) override;
void destroyWidget(QWidget* widget) override;
};
```
`src/MyAppPlugin.cpp`:
`src/my_app_plugin.cpp`:
```cpp
#include "MyAppPlugin.h"
#include "my_app_plugin.h"
#include "MyAppBackend.h"
#include <QDebug>
#include <QDir>
#include <QString>
#include <QQuickWidget>
#include <QQmlContext>
#include <QQuickStyle>
#include <QUrl>
MyAppPlugin::MyAppPlugin(QObject* parent) : QObject(parent) {}
MyAppPlugin::~MyAppPlugin() {}
QWidget* MyAppPlugin::createWidget(LogosAPI* logosAPI) {
auto* widget = new QQuickWidget;
auto* backend = new MyAppBackend(logosAPI, widget);
widget->rootContext()->setContextProperty("backend", backend);
widget->setSource(QUrl("qrc:/qml/Main.qml"));
widget->setResizeMode(QQuickWidget::SizeRootObjectToView);
return widget;
QQuickStyle::setStyle("Basic");
auto* quickWidget = new QQuickWidget();
quickWidget->setMinimumSize(800, 600);
quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView);
auto* backend = new MyAppBackend(logosAPI, quickWidget);
quickWidget->rootContext()->setContextProperty("backend", backend);
// Dev mode: export QML_PATH=$PWD/src/qml to load from disk without rebuilding
const QString devSource = QString::fromUtf8(qgetenv("QML_PATH"));
const QUrl qmlUrl = devSource.isEmpty()
? QUrl("qrc:/src/qml/Main.qml")
: QUrl::fromLocalFile(QDir(devSource).filePath("Main.qml"));
quickWidget->setSource(qmlUrl);
if (quickWidget->status() == QQuickWidget::Error) {
qWarning() << "MyAppPlugin: failed to load QML from" << qmlUrl;
for (const auto& e : quickWidget->errors())
qWarning() << e.toString();
}
return quickWidget;
}
void MyAppPlugin::destroyWidget(QWidget* widget) {
@@ -88,103 +213,129 @@ void MyAppPlugin::destroyWidget(QWidget* widget) {
}
```
## Step 4: Create the Backend Class
## Step 6: Backend Class
`src/MyAppBackend.h`:
```cpp
#pragma once
#include <QObject>
#include <QString>
#include <QVariantList>
class LogosAPI;
class MyAppBackend : public QObject {
Q_OBJECT
Q_PROPERTY(QVariantList items READ items NOTIFY itemsChanged)
Q_PROPERTY(QVariantList items READ items NOTIFY itemsChanged)
Q_PROPERTY(int itemCount READ itemCount NOTIFY itemCountChanged)
Q_PROPERTY(QString statusMessage READ statusMessage NOTIFY statusMessageChanged)
public:
explicit MyAppBackend(LogosAPI* api, QObject* parent = nullptr);
QVariantList items() const;
Q_INVOKABLE void addItem(const QString& name);
QVariantList items() const;
int itemCount() const;
QString statusMessage() const;
Q_INVOKABLE void addItem(const QString& text);
Q_INVOKABLE void removeItem(int index);
Q_INVOKABLE void clearAll();
signals:
void itemsChanged();
void itemCountChanged();
void statusMessageChanged();
private:
LogosAPI* m_api;
QVariantList m_items;
QString m_statusMessage;
};
```
**Rules for the backend:**
- All business logic lives here, not in QML
- Expose data via `Q_PROPERTY` with NOTIFY signals
- Expose actions via `Q_INVOKABLE` methods
- Call other modules via `LogosAPI::callModule()` here
- Call other modules via `LogosAPI`* here (never from QML JS)
- `statusMessage` pattern is useful for showing feedback in the status bar
## Step 5: Create Main.qml
`src/qml/Main.qml`:
## Step 7: `src/qml/Main.qml`
```qml
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Logos.Theme
import Logos.Controls
Rectangle {
anchors.fill: parent
color: Logos.Theme.backgroundColor
color: "#1e1e1e" // when inside Basecamp use Logos.Theme.backgroundColor
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 12
LogosText {
Text {
text: "My App"
font.pixelSize: 24
color: "#ffffff"
}
ListView {
Layout.fillWidth: true
Layout.fillHeight: true
model: backend.items
delegate: LogosText {
delegate: Text {
text: modelData.name
color: "#ffffff"
}
}
LogosButton {
Button {
text: "Add Item"
onClicked: backend.addItem("New Item")
}
Text {
text: backend.statusMessage
color: "#a0a0a0"
font.pixelSize: 12
}
}
}
```
**QML Rules:**
- Use `Logos.Theme` for all colors
- Use `Logos.Controls` for interactive elements
- Access C++ backend via `backend` context property
- Access C++ backend via `backend` context property (set in `createWidget`)
- Bind to `Q_PROPERTY` values; react to signals via `Connections { target: backend }`
- When running inside Basecamp you can use `Logos.Theme` for colors and `Logos.Controls` for styled components
- When running standalone (`nix run .`) use plain QtQuick (no Logos.Theme available)
- Never put business logic in JavaScript
## Step 6: Create resources.qrc
## Step 8: Dev Mode (QML changes without rebuild)
`src/qml/resources.qrc`:
```xml
<RCC>
<qresource prefix="/">
<file>qml/Main.qml</file>
</qresource>
</RCC>
```bash
export QML_PATH=$PWD/src/qml
nix run . # loads Main.qml from disk
# Edit src/qml/Main.qml, then restart — no nix build needed
```
## Step 7: Test in Basecamp
C++ changes (`.h`, `.cpp`, `CMakeLists.txt`, `metadata.json`) always require `nix build`.
## Step 9: Build and Test
```bash
git init && git add -A # nix needs files tracked
nix build # compiles the plugin
nix run . # launches standalone app (nix run .#app also works)
```
## Step 10: Load in Basecamp
```bash
nix build
@@ -192,4 +343,3 @@ cp -r result/* ~/.local/share/Logos/LogosBasecampDev/plugins/my_app/
# Launch Basecamp — find my_app in sidebar
```
QML changes hot-reload when running in dev mode (`./run-dev.sh`). C++ changes require a rebuild.
+20 -12
View File
@@ -1,19 +1,27 @@
# UI App Template
This template is used by `logos-dev-boost init <name> --type ui-app` to scaffold a new Basecamp UI app.
This template documents what `logos-dev-boost init <name> --type ui-app` scaffolds (implemented in `mcp-server/tools/scaffold.ts`).
## Generated files
- `src/<Name>Plugin.h/cpp` — IComponent implementation with createWidget/destroyWidget
- `src/<Name>Backend.h/cpp` — QObject backend exposed to QML
- `src/qml/Main.qml` — QML entry point
- `metadata.json``"type": "ui"`
- `CMakeLists.txt` — Qt6 Quick/QuickWidgets dependencies
- `flake.nix` — Standard module builder config
- `interfaces/IComponent.h` — vendored `IComponent` interface (same pattern as `logos-package-manager-ui` and other Basecamp UI plugins)
- `src/<name>_plugin.h` / `src/<name>_plugin.cpp``IComponent` implementation: `QQuickWidget`, `QQuickStyle::setStyle("Basic")`, optional `QML_PATH` dev mode, loads `qrc:/src/qml/Main.qml` or `Main.qml` from `QML_PATH`
- `src/<Pascal>Backend.h` / `src/<Pascal>Backend.cpp` — QObject backend with `Q_PROPERTY` / `Q_INVOKABLE` / signals (sample notes list + status line)
- `src/qml/Main.qml` — dark-themed QML (toolbar, list, status bar)
- `metadata.json``"type": "ui"`, `main`: `<name>_plugin`
- `CMakeLists.txt``INCLUDE_DIRS` for `interfaces/`, `qt_add_resources` for QML, Qt6 Widgets/Quick/QuickWidgets/QuickControls2
- `flake.nix``mkLogosModule` with `nix-bundle-lgx` input; adds `apps.<system>.app` as an alias of `default` so `nix run .#app` works
- `.gitignore``result`, `build/`, `.DS_Store`
## C++/QML Boundary
## Dev mode (QML without rebuild)
The generated code establishes the correct C++/QML boundary:
- Business logic: C++ backend class (Q_PROPERTY + Q_INVOKABLE)
- UI layout: QML with Logos.Theme and Logos.Controls
- Bridge: Backend set as context property on QQuickWidget
```bash
export QML_PATH=$PWD/src/qml
nix run .
```
## C++/QML boundary
- Business logic: C++ backend (`Q_PROPERTY`, `Q_INVOKABLE`, signals)
- UI: QML (`QtQuick` / `Controls` / `Layouts`); inside Basecamp you can also use `Logos.Theme` and `Logos.Controls`
- Bridge: `backend` context property on the `QQuickWidget` root context