Architecture
Two halves, one narrow boundary
Rust does not call the Compose API. It describes a UI tree as values, fixed-layout records, and a general-purpose interpreter on the Kotlin side turns them into a real Compose tree. Cash App's Redwood and Jetpack Glance use the same pattern.
Host and Renderer
┌──────────────────────── Host (Rust) ────────────────────────┐
│ your code │
│ the layer that builds the tree (Runtime) │
│ compose-rust: schema, fixed-layout records, one batch │
└──────────────────────────────┬──────────────────────────────┘
│
synchronous, same-thread direct calls
primitives, pointers and lengths only
│
┌──────────────────────────────┴──────────────────────────────┐
│ generated shims (C exports, JNI, wasm) │
│ protocol decoder ──► node table │
│ schema interpreter ──► Compose │
└──────────────────── Renderer (Kotlin, AOT) ─────────────────┘
- Host
- The Rust side. Owns your code, your domain logic, and the runtime that builds the tree.
- Runtime
- Whatever builds and changes the tree, behind the
Runtimetrait. In 1.0.0 that is compose-rust's own recomposition runtime. - Renderer
- The ahead-of-time compiled Kotlin and Compose library, including the schema interpreter.
- Schema
- The closed set of widgets, properties, modifiers, roles and events the renderer understands.
- Mutation
- One tree-change record, such as
Create,SetProp,SetModifier,Insert,Move,Remove,SetTextorAppendText. - Event
- A record travelling the other way: a click, a key, a range request, a size class change.
The C ABI surface
The boundary is deliberately tiny. The model is JSI's, not the old React Native bridge's: the same thread, direct calls and synchronous return values.
int32_t compose_rust_host_init(const uint8_t* handshake, uint32_t len, MutationBatch* out);
int32_t compose_rust_host_dispatch_event(const uint8_t* event, uint32_t len, MutationBatch* out);
int32_t compose_rust_host_render_frame(uint64_t frame_time_nanos, MutationBatch* out);
void compose_rust_host_release_batch(MutationBatch* batch);
void compose_rust_host_shutdown(void);
int32_t compose_rust_renderer_run(void); /* blocking; the renderer owns the loop */
void compose_rust_renderer_request_frame(void); /* callable from any thread */
Two rules follow from it.
- Only primitives, pointers and lengths cross. No objects and no closures. Type safety comes back above that line, because the Kotlin types are generated from the single Rust definition.
- Application code never calls these functions. The Host calls them, and a worker thread
asks for a frame through
request_frame_from_worker.
The handshake exchanges a schema hash, a protocol version and the loop mode. A hash mismatch
fails initialisation rather than producing a subtly wrong UI. The status codes are
0 for success and negative values for protocol errors, an uninitialised Host,
double initialisation and caught panics. Nothing unwinds across the boundary, because a
protocol error has to become a ProtocolError event, never a process abort.
Two loop modes exist. On the desktop and iOS, Rust's main calls
compose_rust_renderer_run and the renderer owns the loop from there. On Android
and the web, the platform owns the loop and the renderer calls
compose_rust_host_init first. The application is the same in both.
The protocol
Both halves are in one process, so the encoding minimises copying rather than bytes on a wire. Records are fixed-layout and read in place. There is no decode step and no serde-style format on the hot path.
- A record is a
u16tag, au16length, then fixed fields. Little-endian, 4-byte aligned. - Strings live in the same arena as
(offset, len)pairs, in UTF-8. The renderer makes a KotlinStringonly when it hands the text to Compose. - The batch is
MutationBatch { ptr, len, result }, pointing into a Host-owned arena that is reused every frame.resultcarries the handler's synchronous return value: for a key event, whether it was consumed.
A batch is applied inside a single snapshot, so an intermediate tree can never be drawn. The renderer applies it in the same call stack and releases it at once. No batch is ever queued.
The frame model
- The renderer receives input and calls
dispatch_eventon its UI thread. - The Host runs the handler, the runtime writes what changed into the arena, and the Host returns the batch and the result value.
- The renderer applies the batch in one snapshot, releases it, and reports the result to Compose, for example as a handled key event.
- A worker thread that changed state causes a frame request. Requests coalesce into one
render_frameon the next tick of the Compose frame clock.
The runtime runs on the renderer's UI thread, not on a thread of its own. That removes the need for locks and for a queue. The only cross-thread signal in the design is the frame request. A frame requested while a handler runs is held until the handler returns, so the renderer is never re-entered from inside a dispatch.
The measure call Planned
A layer that computes its own layout needs to know how big text will be before it places it. The measure call lets the Host ask the renderer, in the middle of its layout, how much room a piece of text or an already-sent widget takes within given constraints. It is tracked in #106.
int32_t compose_rust_renderer_measure(const uint8_t* requests, uint32_t len,
uint32_t count, MeasureResult* results);
- The answer equals the drawing. The renderer measures with the same font resolution, the same density and font scale, and the same type scale it draws with. That equality is the reason the call exists.
- Many requests per call. One layout pass can ask about hundreds of text leaves, so one call carries them all, as fixed-layout records in a buffer like the batch.
- Same thread, same stack. The Host makes the call while the renderer is already inside a call into the Host. It is a re-entrant call on the UI thread, with no queue and no thread hop. Called from another thread, it measures nothing and returns an error.
- Only applied nodes. A widget sent in the batch being built cannot be measured yet, because that batch is applied only after the Host returns.
Application code does not call it. The layer that builds the tree reaches it through a safe Rust API that only works inside the Host's frame work.
Zoom Planned
compose-rust zooms the way VS Code does: the whole window scales, and the starting point is the operating system's text size.
- The factor is the OS text scale times the app zoom. The app zoom is
1.2to the power of an integer level from -8 to 8, the same steps as VS Code. macOS has no public text size setting, so its OS factor is 1. - The keys are ⌘+, ⌘- and ⌘0 on macOS, and the same with Ctrl on Windows and Linux. The renderer handles them, unless the application consumed the key first. The Host can also set the level, for a View menu's zoom items.
- The level is UI-local state, so the renderer saves it per application in the platform's settings store and starts from it next time.
- The Host is told once at startup and again whenever the factor changes, with the factor, the OS part and the level. Moving the window to a monitor with a different density does not change the factor and sends nothing.
- Measuring follows zoom. Each measure request carries the factor, so a measurement and the drawing agree at every zoom level.
Today the desktop renderers fix the font scale at 1 and ignore the OS text size (#68).
Schema codegen
The C ABI carries only bytes, so type safety is layered on top. Widgets, properties, modifiers, roles and event payloads are defined once in Rust. The Kotlin types, the codecs, the JNI shims for Android and the wasm shims for the web are generated from that definition, and a checked-in test fails when the generated output is stale. Nobody writes JNI or cinterop glue by hand.
What runs where
The renderer is built two ways. Where Compose can be compiled for the platform directly, there is no virtual machine in the result. Where it cannot yet, the renderer is a GraalVM native image with a Java runtime compiled into it. Neither needs Java installed on the user's machine.
| Platform | 0.0.1 ships | Next |
|---|---|---|
| macOS | Native image | Kotlin/Native, linked into the executable; runs in CI (#22) |
| Linux | Native image | Kotlin/Native, linked into the executable; passes a startup test in CI (#22) |
| Windows | Native image | Kotlin/Native (#24) |
| iOS | Kotlin/Native static archive | unchanged |
| Android | Android library | unchanged |
| Web | Kotlin/Wasm | unchanged |
The difference is most of the weight. The notepad sample on macOS with the Kotlin/Native renderer is one 28.76 MB file with nothing beside it. The same sample built with the native image was 93.1 MB across four files. The extra is not Compose and not compose-rust's own code, but the Java runtime and the metadata a closed world needs.
Linux has no published Compose UI for Kotlin/Native either, but nothing prevents building it, so the pinned Compose fork adds the Linux targets. Windows is harder: Kotlin/Native's target there is MinGW and Skia's build is MSVC, so the two cannot simply be linked.
The boundary is the same C ABI on every renderer, the records are the same bytes, and the interpreter is the same source. Nothing you write changes.
The Kotlin/Native renderer on macOS
The window is the renderer's own NSWindow, not the one Compose opens for this
platform. Compose's window draws correctly, but nothing in it is named: a screen reader sees
the title and the three title bar buttons and nothing the application drew. Owning the window
makes the contents readable.
The view's backing layer is a CAMetalLayer, and Skia draws into it through Metal.
A frame is presented inside the same transaction that resizes the window, so the contents
follow the window edge during a drag instead of trailing behind it.
Text goes through the platform rather than around it. The view implements
NSTextInputClient, so marked text, the candidate window and Korean, Japanese and
Chinese composition are the input method's own work. The menu a selection offers is a real
NSMenu.
AppKit demands the main thread. The renderer runs NSApplication on the thread it
was called on, and the call returns when the window closes. So
compose_rust_renderer_run must be called on the process main thread.