How CrownUI works
Architecture
How the CrownUI workspace is split into layered crates, what each layer may depend on, and how one publishable crate is assembled from them.
CrownUI is a Cargo workspace of small crates. Each crate sits in one layer, and a crate may only depend on crates in lower layers. The file layers.toml at the workspace root defines the layers, and cargo xtask layers fails when a dependency breaks them.
The layers
layers.toml lists eight layers from the bottom up. The order in the file is the order of the layers.
[layers]
base = ["geometry"]
foundations = ["input", "reactive", "motion", "inspect", "crownconfig", "audio"]
contracts = ["windowing", "graphics", "layouting", "semantics", "gesture"]
implementations = ["impeller", "taffy-layout", "accessibility", "mcp"]
core = ["view", "runtime"]
components = ["widgets"]
public = ["crownui", "crowntest", "crownmcp", "crowndev", "capture"]
tools = ["xtask"]
[allow]
runtime = ["view"]
crowndev = ["crownui"]
capture = ["crowntest"]A crate may depend on any crate in a lower layer. It may not depend on a crate in its own layer or a higher one. The [allow] table names the only exceptions, and there are three of them:
runtimeusesview, because the runner drives theUipipeline thatviewdefines.crowndevmay usecrownui. Today the devtools CLI only depends onmcp, so this edge is reserved and unused.captureusescrowntest, because it mounts apps through the test harness before it renders them.
The crate map
| Layer | Crate | What it holds |
|---|---|---|
| base | geometry | Point, Rect, Color, Transform, CornerShape and Length, which prints numbers in a stable form for snapshots. |
| foundations | input | DOM-named input events, Keybind and Modifiers. |
| foundations | reactive | The Runtime that owns every signal, memo, store, effect and scope. |
| foundations | motion | Springs and curves evaluated in closed form. The CrownOS compositor uses the same crate. |
| foundations | inspect | Plain snapshot types for devtools, such as FrameStats. |
| foundations | crownconfig | The only door to crownos-config: appearance, theme overrides, keybinds and file watching. |
| foundations | audio | Microphone capture over PipeWire. Off by default. |
| contracts | windowing | Platform, WindowControl, PlatformSurface, ApplicationHandler, and the wayland and headless backends behind features. |
| contracts | graphics | DrawList, Composition, GraphicsBackend, Renderer, TextSystem and the RecordingBackend. |
| contracts | layouting | LayoutStyle in CSS flexbox and grid terms, and the LayoutEngine trait. |
| contracts | semantics | The SemanticTree, Role, UiAction and queries. |
| contracts | gesture | Gesture recognizers, the gesture arena and focus. |
| implementations | impeller | The default renderer on Flutter's Impeller, with Vulkan and GLES. |
| implementations | taffy-layout | The default LayoutEngine, built on taffy. |
| implementations | accessibility | The bridge from the semantic tree to AccessKit and AT-SPI. |
| implementations | mcp | The in-app MCP server that agents talk to. |
| core | view | Element, Kind, modifiers, Cx, the element tree and the Ui pipeline. |
| core | runtime | Runner, the render thread, RenderTree, AppConfig and InlineApp. |
| components | widgets | Primitive widgets such as text, button and scroll_view, and the themed widgets::kit. |
| public | crownui | The facade crate apps depend on, with prelude, ext, kit, run and run_windows. |
| public | crowntest | The test harness: TestApp, queries and text snapshots. |
| public | crownmcp | A stdio MCP server that agents launch to reach running apps. |
| public | crowndev | A devtools CLI over the same app sockets. |
| public | capture | Turns app states into editable design scenes. |
| tools | xtask | Workspace automation, run as cargo xtask <command>. |
Every crate has a README.md that states its purpose, public API, invariants and non-goals. In every crate except xtask, that file is also the crate-level rustdoc, so it ships with the code it describes.
What each layer is for
The contracts layer is the center of the design. windowing, graphics and layouting define traits and plain data, and nothing else. They contain no GPU types, no taffy types and no Wayland types. Everything above them talks to the traits. Everything in the implementations layer fills them in.
That split decides where code goes:
- A new drawing command goes into
graphicsas plain data, andimpellerlearns to translate it. - A new widget goes into
widgetsand only seesview,graphicsand the contracts. It never sees Impeller. - A new platform feature goes into
windowingas a method or aWindowEvent, and each backend implements it.
The core layer joins the pieces. view holds the retained element tree and turns events, signals and layout into render ops and a semantic tree. runtime runs that pipeline on a UI thread and hands the ops to a render thread. See Frame pipeline for the details.
Enforcing the layers
cargo xtask layers
cargo xtask layers --jsonThe command reads layers.toml and ranks each crate by the position of its layer in the file. It then reads cargo metadata for every workspace member and checks each dependency that is a workspace crate with a path. Dev-dependencies are skipped, so a crate's tests may use crowntest even though crowntest sits higher. The check fails when:
- a member is missing from
layers.toml, or - a member depends on a crate in the same or a higher layer that
[allow]does not list for it.
Each crate becomes one step in the report, and a failing step names the offending dependencies. --json prints the same report as {"command", "ok", "steps"} so agents can parse it.
To add a crate, put it in a layer in layers.toml first. If it needs a crate from its own layer, add the edge to [allow] and say why in review. An allowed edge is a design decision, so keep the list short.
The facade crate
Apps depend on one crate, crownui. It re-exports what an app author needs and hides the workspace layout.
use crownui::prelude::*;
fn main() -> Result<(), crownui::RunError> {
crownui::run(
component(|cx| {
let count = cx.signal(0);
vstack((
text(bind(move |rt| format!("Count: {}", count.get(rt)))).text_size(24.0),
button("+1")
.on_click(move |cx, _| count.update(cx, |count| *count += 1))
.hover(|style| style.scale(1.04))
.transition(Spring::snappy()),
))
.p(24.0)
.gap(12.0)
}),
AppConfig::new("Counter"),
)
}The facade has four parts:
crownui::preludeholds views, widgets, modifiers, signals, colors, springs and window options.crownui::kitiswidgets::kit, the themed CrownOS widgets.crownui::extholds extension points for component libraries and backends:Kind,DrawList,Listener,Runner,InlineApp,Uiand the contract traits.crownui::runandcrownui::run_windowsstart an app on Wayland with Impeller and taffy.
The default features are wayland, impeller, tokio, mcp and accessibility. The static feature links libimpeller.a into the binary. The audio feature adds microphone capture. Both are off by default.
Publishing one crate
The workspace crates are not published one by one. cargo xtask bundle assembles a single crownui crate that contains every library as a module.
cargo xtask bundle
cargo publish --manifest-path target/bundle/crownui/Cargo.tomlThe bundle step does this:
- It copies each library into
target/bundle/crownuias a module named after the crate, such ascrownui::view,crownui::runtimeorcrownui::taffy_layout. - It rewrites each
crate::path in the copied sources tocrate::<module>::and brings the sibling modules into scope by name, so the code compiles unchanged inside one crate. - It also bundles the CrownOS crates the facade reaches outside the workspace as hidden modules:
crownos_config,crownos_protocolsbehindwayland, andcrownos_ipcandvoice_protocolbehindaudio. - It writes the manifest from
xtask/bundle/manifest.toml, where crates likeimpeller,accessibility,mcpandaudiosit behind the features of the same name. - It runs
cargo packageon the result to prove it builds.
Inside the workspace you write view::Cx. In the published crate the same type is crownui::view::Cx, and the facade paths such as crownui::prelude::Cx are the same in both.
Workspace rules
These rules hold in every crate and keep the layers honest:
- No
Mutex,RefCell,RcorArc<Mutex<..>>in framework code. State lives inreactive::Runtimeand is reached through&mut Cx. Threads talk through channels only. - Libraries use
thiserror. Binaries, examples and xtask useanyhow. Libraries never callunwraporexpect. - One concept per file.
mod.rsandlib.rsonly declare and re-export. Helpers live insrc/util/<name>.rs. - No macros in the public authoring API, so rust-analyzer and agents see real types.
- Diagnostics carry a stable code such as
CUI0001, indexed in Errors.
Why it works this way
The layers exist so that each backend is a type parameter and not a fork. runtime::Runner is generic over a GraphicsBackend and a LayoutEngine, and it drives any windowing::Platform. Tests swap Impeller for the RecordingBackend and Wayland for the headless platform, and they run the same view and runtime code an app runs. That only works because view and runtime never name a concrete backend. The layer check turns that promise into a build failure instead of a review comment. See Backends.
The contracts carry plain data for the same reason. A DrawList prints as stable text, so a snapshot test, a devtools view and an AI agent read the same thing the renderer draws. A SemanticTree is plain data, so the screen reader bridge, the MCP server and the test harness share one tree. See Semantics.
The single published crate exists because users want one dependency and one version. The workspace still compiles each crate on its own, which keeps incremental builds small and lets cargo xtask check test only the crates a change touched.
What it costs
- Indirection. A renderer, a text system and a layout engine meet through traits with associated types. Shaped text crosses from the UI thread to the renderer as a
TextLayoutId, becausegraphicsmay not name Impeller's paragraph type. Reading the flow takes more files than a single-crate toolkit. - Generics.
Runner<G, L>andInlineApp<G, L>are monomorphized for each backend pair, so each pair costs compile time and binary size. Apps use one pair, so this mostly hits the workspace's own tests. - Same-layer friction. Two crates in one layer cannot share code without an
[allow]edge or a lower crate to hold it. That is on purpose, and it sometimes means moving a type down a layer before a feature can land. - Two sets of paths. Workspace code says
view::Cxand the published crate sayscrownui::view::Cx. The bundle step rewrites paths with text replacement, so a new path pattern in a bundled crate needs a matching rule inxtask/src/command/bundle. - Contracts first. A backend feature that the contract does not express yet needs a contract change before any backend can use it. Wayland-only protocols live on
WaylandPlatformand arrive asWaylandEventuntil a neutral form exists.