Ways to make an app

Use libzrnui, in whichever of its four languages you like. The older Python and Qt SDK is what the current phone image can already run, so it stays documented, but new work belongs on libzrnui.

WayStatusDraws withShips as
C, zrnui.hLaptop only so farlibzrnui, GLES2.zexec
C++, zrnui.hppLaptop only so farlibzrnui, GLES2.zexec
Rust, crate zrnuiLaptop only so far. Lacks pictures, long lists, pivots, footer, bubbleslibzrnui, GLES2.zexec
Python, import zrnuiLaptop only so far. Lacks pictures, long lists, pivots, footer, bubbleslibzrnui, GLES2, no Qt.zexec, any CPU
Older Python SDK, ztosOlder. Runs on the phoneQt, with a GPU layer for motionAn aarch64 ELF launcher plus a zip
ZrnMetroCore, ztmetro/coreThe ztOS apps themselves. Not an SDKCairo and Pango, GPU quadsPart of the system

One library, four languages

One C library sits under all of them. Bindings pass handles and closures and draw nothing themselves, so what they share produces the same pixels. Pictures, long lists, pivots, the footer and bubbles are in C and C++ only at this commit.

#include <zrnui.h>

static zui_node *status;

static void tapped(zui_node *button, void *data) {
	zui_set_text(status, "Tapped");
	zui_toast(zui_node_app(button), "Hello from Notes");
}

int main(void) {
	zui_app *app = zui_app_new("io.github.you.notes", "Notes");
	zui_node *page = zui_page(app, "Notes");
	zui_node *card = zui_section(page, "Status");
	status = zui_heading(card, "Not tapped yet");
	zui_button(page, "Tap me", tapped, NULL);
	return zui_run(app, page);
}
#include <zrnui.hpp>

int main() {
	zrn::ui::App app("io.github.you.notes", "Notes");
	auto page = app.page("Notes");
	auto status = page.section("Status").heading("Not tapped yet");
	page.button("Tap me", [&]() {
		status.set_text("Tapped");
		app.toast("Hello from Notes");
	});
	return app.run(page);
}
fn main() {
    let app = zrnui::App::new("io.github.you.notes", "Notes");
    let page = app.page("Notes");
    let status = page.section("Status").heading("Not tapped yet");
    let app_ref = &app;
    page.button("Tap me").on_click(move || {
        status.set_text("Tapped");
        app_ref.toast("Hello from Notes");
    });
    std::process::exit(app.run(page));
}
import zrnui

app = zrnui.App("io.github.you.notes", "Notes")
page = app.page("Notes")
status = page.section("Status").heading("Not tapped yet")


def tapped():
    status.set_text("Tapped")
    app.toast("Hello from Notes")


page.button("Tap me", tapped)
app.run(page)
LanguageMemory and callbacksDifferences to know
COne callback shape: void cb(zui_node *node, void *data). zui_event(node) says why it ran. zui_set_handler takes a free_dataNames start zui_
C++Header only, namespace zrn::ui. App frees everything. Node is a handle you can copytoggle, not switch. No watch_fd: use zui_watch_fd(app.get(), ...)
RustEvery Node<'a> is tied to its App by lifetime. Closures are boxed and dropped with the nodeHandlers: on_click, on_change, on_slide, on_edit, on_event. Other threads use app.poster(). No watch_fd wrapper
Pythonctypes, with callbacks kept until the node is freed. An exception in a callback prints and does not cross into CKeyword handlers and properties: .detail=, .value=, .on=, .icon=

Pages and moving between them

An app is a set of pages. Back on the first page closes the app. The hardware Back, Escape and a tap on the title all go back.

static void open_wifi(zui_node *row, void *wifi_page) { zui_push(wifi_page); }

zui_node *wifi = zui_page(app, "Wi-Fi");
zui_node *page = zui_page(app, "Settings");
zui_item(zui_card(page), "Wi-Fi", NULL, open_wifi, wifi);
return zui_run(app, page);
auto wifi = app.page("Wi-Fi");
auto page = app.page("Settings");
page.card().item("Wi-Fi", "", [&] { wifi.push(); });
return app.run(page);
let wifi = app.page("Wi-Fi");
let page = app.page("Settings");
page.card().item("Wi-Fi", None).on_click(move || wifi.push());
std::process::exit(app.run(page));
wifi = app.page("Wi-Fi")
page = app.page("Settings")
page.card().item("Wi-Fi", on_click=wifi.push)
app.run(page)

The main loop

All calls come from one thread, the one that called run. post is the only way in from another. There is one app per process.

static void tick(void *data) { zui_toast(data, "A second passed"); }
static void done(void *data) { zui_busy(data, false); zui_toast(data, "Done"); }

int id = zui_timer(app, 1000, true, tick, app);   /* every second */
zui_timer_cancel(app, id);
zui_busy(app, true);
zui_post(app, done, app);                         /* from any thread */
zui_watch_fd(app, fd, ready, NULL);               /* ready(fd, data) when readable */
zui_haptic("tap");                                /* tick, tap, click, heavy */
int id = app.timer(1000, true, [&] { app.toast("A second passed"); });
app.cancel_timer(id);
app.busy(true);
app.post([&] { app.busy(false); app.toast("Done"); });   // from any thread
zui_watch_fd(app.get(), fd, ready, nullptr);             // C API
zui_haptic("tap");
let id = app.timer(1000, true, move || println!("a second passed"));
app.cancel_timer(id);
app.busy(true);
let poster = app.poster();                 // Send + Clone, for other threads
std::thread::spawn(move || { poster.post(|| println!("on the UI thread")); });
tid = app.timer(1000, lambda: app.toast("A second passed"), repeat=True)
app.cancel(tid)
app.busy(True)
app.post(lambda: app.busy(False))          # from any thread
app.watch(fd, lambda fd: read_it(fd))      # fn(fd) when readable

The zrn commands

CommandWhat it does
zrn new NAME --lang c|cpp|python|rust [--id ID] [--dir D]A project from a template. The default id is io.github.$USER.name
zrn build [--arch A]C and C++ against the system's or the SDK's libzrnui. Rust with cargo. Python is syntax-checked
zrn render OUT.png [--page N|TITLE] [--style metro|dotmatrix] [--size WxH] [--scale S] [--scroll Y]Draws a page, no display needed
zrn run [--headless] [--script S] [--style] [--size] [--scale] [--timeout T]In your Wayland session, else a nested compositor. --headless: a virtual one
zrn pack [--arch] [--out D] [--key K] [--no-sign] [--bundle-zrnui]Makes dist/<id>-<version>-<arch>.zexec and .minisig
zrn verify FILE [--sig S] [--key K]Exit 0 good, 1 broken, 2 not trusted
zrn install FILE [--key K], zrn uninstall IDFor this user, on this computer
zrn info FILEThe manifest and the files inside
zrn exec FILEWhat zrn-exec does: verify, unpack once, run
zrn keygen [--name N] [--output O] [--force] [--no-trust]A signing key. zrn pack makes one if you have none
zrn submit [--repo R] [--commit C] [--contact C] [--notes N] [--send]Shows the store entry for HEAD. Only --send sends it
zrn doctorThe toolchain, and how to fix what is missing
zrn init-agents [--agent a,b] [--all] [--list] [--force]Instruction files and the zrnui skill for coding agents

Every command takes --json, which prints one object on stdout. Errors look like {"ok": false, "error": {"code", "message", "hint"}}. Exit 3 is a usage error. -C DIR runs in another project folder.

With a coding agent

zrn init-agents --list
claude       CLAUDE.md                            .claude/skills/zrnui
codex        AGENTS.md                            .agents/skills/zrnui
gemini       GEMINI.md                            .gemini/skills/zrnui
cursor       .cursor/rules/zrnui.mdc              .cursor/skills/zrnui
copilot      .github/copilot-instructions.md      .github/skills/zrnui
opencode     AGENTS.md                            .opencode/skills/zrnui
antigravity  .agent/rules/zrnui.md                .agent/skills/zrnui
windsurf     .windsurf/rules/zrnui.md             .windsurf/skills/zrnui

It only writes into an app's own folder, one with zrnapp.toml. An agent can check its own work with zrn render and read the picture. The paths for Cursor, Windsurf and Gemini are the least certain, as DESIGN.md says.

The older Python SDK older

In the ztOS repository at sdk/. Apps are Python with Qt (PySide6), in the Metro look only. A built app is a small aarch64 ELF launcher with a zip behind it. No screenshots here: nothing from this SDK was rendered for this site.

ztos-sdk new "Notes"     # a new app in ./notes
ztos-sdk run             # a 360x640 window; Escape is back
ztos-sdk designer        # draw pages in Qt Designer
ztos-sdk deploy          # build, install on the phone over SSH, open it
ztos-sdk logs -f
ztos-sdk doctor          # also: build, install, launch, uninstall, list
import ztos

app = ztos.App()

class Home(ztos.Page):
    def __init__(self):
        super().__init__("notes")
        self.entry("new note", "start writing", lambda: app.push(Editor()))
        self.toggle("Sync", on=True, on_change=self.sync)

    def sync(self, on):
        ztos.notify("Notes", "sync on" if on else "sync off")

app.run(Home())

It has things libzrnui lacks at this commit: menu, Dialog, message, notify, store, battery, performance and animate. The project file is ztos.toml, not zrnapp.toml, and its ids reserve both zt and zrn. The full reference is sdk/README.md.

ZrnMetroCore not an SDK

The C toolkit behind ztOS's own apps (ztmetro/core). It builds inside the ztOS tree and has no packaging or stable interface for outside apps. It appears here because its animations are the reference for Metro's motion. DESIGN.md plans to move those apps to libzrnui one at a time.

Checked against zrn-sdk commit ea16771. Pictures are real renders of code that compiled. Source and proof.