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.
| Way | Status | Draws with | Ships as |
|---|---|---|---|
C, zrnui.h | Laptop only so far | libzrnui, GLES2 | .zexec |
C++, zrnui.hpp | Laptop only so far | libzrnui, GLES2 | .zexec |
Rust, crate zrnui | Laptop only so far. Lacks pictures, long lists, pivots, footer, bubbles | libzrnui, GLES2 | .zexec |
Python, import zrnui | Laptop only so far. Lacks pictures, long lists, pivots, footer, bubbles | libzrnui, GLES2, no Qt | .zexec, any CPU |
Older Python SDK, ztos | Older. Runs on the phone | Qt, with a GPU layer for motion | An aarch64 ELF launcher plus a zip |
ZrnMetroCore, ztmetro/core | The ztOS apps themselves. Not an SDK | Cairo and Pango, GPU quads | Part 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)
| Language | Memory and callbacks | Differences to know |
|---|---|---|
| C | One callback shape: void cb(zui_node *node, void *data). zui_event(node) says why it ran. zui_set_handler takes a free_data | Names start zui_ |
| C++ | Header only, namespace zrn::ui. App frees everything. Node is a handle you can copy | toggle, not switch. No watch_fd: use zui_watch_fd(app.get(), ...) |
| Rust | Every Node<'a> is tied to its App by lifetime. Closures are boxed and dropped with the node | Handlers: on_click, on_change, on_slide, on_edit, on_event. Other threads use app.poster(). No watch_fd wrapper |
| Python | ctypes, with callbacks kept until the node is freed. An exception in a callback prints and does not cross into C | Keyword 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
| Command | What 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 ID | For this user, on this computer |
zrn info FILE | The manifest and the files inside |
zrn exec FILE | What 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 doctor | The 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.