Architecture
This document is the map you should have in your head before making a non-trivial change. It explains what each crate owns, how a frame actually gets to the screen, and — most usefully — where a given kind of code belongs.
For the reasoning behind any individual decision, the milestone notes in docs/milestone-*.md are the authoritative record. This is the overview; those are the minutes.
The shape of the thing
frus is four layers of crates. Dependencies only point downward. Nothing below frus-shell is allowed to know which platform it is running on — that single rule is what makes one codebase target desktop, Android, and the web.
┌────────────────────────────────────────────────────────────┐
│ APPLICATION what you write │
│ frus (facade) · frus-hello · frus-demo · frus-transforms │
│ frus-fetch-example · frus-test │
└────────────────────────────────────────────────────────────┘
▲
┌────────────────────────────────────────────────────────────┐
│ SHELL platform layer │
│ frus-shell │
│ window · event loop · lifecycle · IME · a11y · net │
│ FrusApp · Application · Command · Subscription · main! │
└────────────────────────────────────────────────────────────┘
▲
┌────────────────────────────────────────────────────────────┐
│ WIDGETS UI & interaction │
│ frus-widgets │
│ Widget trait · components · router · Ui/scene build │
│ hit-testing · focus · scrolling · drag & drop · theme │
└────────────────────────────────────────────────────────────┘
▲
┌────────────────────────────────────────────────────────────┐
│ FOUNDATIONS render & measure │
│ frus-core geometry, color, paths, animation, Scene │
│ frus-layout flexbox (taffy) frus-text shaping │
│ frus-gpu wgpu renderer frus-image decode │
│ frus-svg SVG as paths frus-l10n Fluent i18n │
└────────────────────────────────────────────────────────────┘
Crate by crate
Foundations
frus-core — the vocabulary everything else speaks. Zero heavy dependencies.
geometry.rs—Point,Size,Rect, insetscolor.rs,hct.rs— color, plus the HCT perceptual color space used for theme generationpath.rs,decoration.rs— vector paths, borders, shadows, gradientstext_style.rs— font, weight, size, decorationscene.rs—SceneandPrimitive: the flat, backend-agnostic display list. This is the contract between widgets and the GPU. A widget paints by pushing primitives; the renderer only ever consumes them.animation/— spring physics, curves,AnimationControllersemantics.rs— accessibility annotations (role, label, value, state)responsive.rs,image.rs— breakpoints,ImageData
frus-layout — flexbox over taffy. Owns Style and turns a style tree into resolved rects. It does not know what a widget is.
frus-text — shaping and measurement over cosmic-text. Given a string, a style, and a width constraint, returns laid-out glyph runs and a measured size.
frus-gpu — the renderer. Consumes a Scene, produces pixels.
renderer.rs— thewgpudevice, surface, and frame looppainter.rs— primitive → draw-call translation, batchingpath.rs—lyontessellation of fills and strokes into trianglesmesh.rs— those triangles kept from frame to frame, keyed by the outline relative to its first pointtext.rs— glyph atlas and text draw viaglyphoncompositor.rs— layers, clips, transformsoffscreen.rs— headless rendering to a buffer; this is what makes golden tests possibleshaders/— WGSL
frus-image — PNG/JPEG decode to ImageData. Deliberately narrow: fewer formats, smaller dependency tree.
frus-lottie — Lottie animations evaluated by velato (no vello) and drawn as frus paths through its RenderSink; older files normalised first. Behind the lottie feature, off by default.
frus-svg — SVG pictures parsed by usvg and drawn as frus paths (fills, expanded strokes, gradient ramps, clipped layers), so they stay sharp at any size. Behind the svg feature, off by default.
frus-l10n — Fluent bundles and locale negotiation. We use Mozilla's Fluent rather than inventing a message format.
Widgets
frus-widgets — the largest crate, ~80 modules. Two halves:
The tree. The Widget<Msg> trait is the core abstraction — generic over the app's message type, which is how interaction stays type-safe end to end. An application made of components never names it: Msg defaults to Callback, a small Send handle to a closure that stays on the interface's thread, so a handler is just || …:
pub trait Widget<Msg> {
fn style(&self) -> Style; // → frus-layout
fn children(&self) -> &[Box<dyn Widget<Msg>>];
fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene);
fn on_click(&self) -> Option<Msg>;
fn key(&self) -> Option<u64>; // stable identity across rebuilds
fn semantics(&self) -> Option<Semantics>; // accessibility
// …
}
Components. component.rs holds StatelessWidget, StatefulWidget/State and the hooks. A component is a node that is built into another before the walk asks anything of it (Widget::expand) and is transparent once built. What it keeps lives in the runtime's StateStore, under the node's place in the tree or its key, and is disposed at the end of the frame that no longer reaches it. router.rs holds GoRouter: routes, the stack of pages, redirects and the transition; a page under the top is kept by Navigator::retain.
The runtime. ui.rs builds a Ui<Msg> from a widget tree: it runs layout, walks the tree painting into a Scene, and populates the registries that make interaction work — hit-testing, focus order and directional focus, scrollable viewports and extents, draggables, reorderables, interactive bounds, semantics.
This is the subtlety most new contributors hit.
Uiresolves a widget's rect through per-kind registries. A new interactive widget that isn't registered in the right one will pass its unit tests and still silently do nothing on live hover, drag, or scroll paths. If you add an interactive widget, wire it into the registry it belongs to.
Everything else in the crate is widgets — button.rs, scroll.rs, datatable.rs, kanban.rs, textinput.rs, navigator.rs, and so on — plus theme.rs (the overridable design tokens), interaction.rs (Status, Cursor, Key), dsl.rs (the column! / row! macros), and inspector.rs (tree dumps for debugging).
Shell
frus-shell — the only crate that knows about platforms. Everything platform-specific is #[cfg]-gated here and nowhere else.
application.rs— theApplicationtrait:update,view,subscription,init,on_lifecycle,title, theming. This is the contract the shell drives.widget_app.rs—FrusApp, anApplicationwhose message is aCallbackand whoseupdateruns it: the application an author writes with components. It carries the root component or the router, and forwards the router's transition and back gesture.app.rs— the driver: event loop, frame scheduling, animation ticking, input dispatch intoUicommand.rs—Command<Msg>: effects. Sync tasks,perform_asyncfutures, batching. Native runs them on a thread; the web runs them throughspawn_local.subscription.rs— continuous message sources (timers, streams), started and stopped by diffing whatApplication::subscriptionreturns each cyclegesture.rs— taps, long-press, drag, fling, back-gesture, inertiaa11y.rs— AccessKit bridge (desktop)android_ime.rs— real Android IME: composition, swipe input, insetsnet.rs—fetch/Requestbehind thenetfeature;ureqnatively, browserfetchon wasm.jsonadds serde-typed bodies and responses.file_dialog.rs—FileDialog, the system's open / save / folder dialogs behind thefile-dialogsfeature (desktop: rfd, owned by the application's window); refused on the interface's threadnotification.rs—SystemNotification, the system's notifications behind thenotificationsfeature (desktop: notify-rust; Windows records the application under the current user; Android:android_notification.rsandFrusNotificationsin the bundled dex; the Web:notification_web.rs, the browser'sNotification, throughshow_async); waits for a click, refused on the interface's thread, a click brings the window forwardtaskbar.rs—Taskbar, the button's count and progress, declared from the state; behind thetaskbarfeature (WindowsITaskbarList3and a drawn overlay, macOS Dock badge, Linux launcher entry over D-Bus)shortcuts.rs—GlobalShortcut, combinations reaching the application from any window, declared from the state; behind theglobal-shortcutsfeature (global-hotkey: Windows, macOS, X11)tray.rs—Tray, the tray icon, declared from the state; behind thetrayfeature on the desktop (tray-icon, withksnion Linux): the shell builds it, changes what differs, acts on its events inabout_to_waitinterface_thread.rs— which thread is the interface's, for the calls that must not run therereload.rs— dev live-reloadremote.rs— remote/debug channellib.rs— themain!macro, which generates the desktopmain, the Androidandroid_main, and the wasm#[wasm_bindgen(start)]from one declaration
Application layer
frus — the facade. Re-exports shell + widgets + main! so an app declares exactly one dependency. Features net, json, file-dialogs, notifications, tray, taskbar and global-shortcuts propagate downward. It contains no logic; keep it that way.
frus-test — headless rendering, Snapshot, and assert_golden / assert_golden_with against reference PNGs.
frus-hello, frus-demo, frus-transforms, frus-fetch-example — sample apps. frus-hello is also the source of the application template in crates/frus-cli/template; if you change one, change the other.
frus-cli — the frus program. frus create writes a new application from the template, which is built into the program so that cargo install frus-cli is all a developer needs. It depends on nothing, and does nothing after the first command: from there on the tools are cargo's.
How a frame happens
event (click / key / touch / timer / async result)
│
▼
frus-shell dispatch: hit-test through Ui → Msg
│
▼
Application::update(&mut self, msg) -> Command<Msg> ← pure. no GPU, no window.
│ │
│ └─→ effect runs (thread or spawn_local)
│ └─→ produces another Msg, loops back
▼
Application::view(&self, theme, w, h) -> Box<dyn Widget<Msg>>
│
▼
frus-widgets build_ui:
│ style tree ──→ frus-layout (taffy) ──→ resolved rects
│ text nodes ──→ frus-text (cosmic-text) ──→ measured runs
│ paint walk ──→ Scene (flat primitive list)
│ registries ──→ hit / focus / scroll / drag / semantics
▼
frus-gpu Scene ──→ batched draw calls ──→ wgpu ──→ Vulkan / Metal / DX12 / WebGPU
│
▼
pixels
Two properties fall out of this and are worth protecting:
updatenever touches the GPU. That's why most of the test suite runs headless.Sceneis the only thing crossing into the renderer. Widgets can't reach the GPU directly, so a new backend would only need to consumeScene.
Where does my code go?
| I want to… | It goes in |
|---|---|
| Add a widget | frus-widgets/src/<name>.rs, exported from lib.rs. Register it if it's interactive. |
Change what a component can keep, or how a State lives | frus-widgets/src/component.rs |
| Change how pages are matched, redirected or moved between | frus-widgets/src/router.rs |
| Add a paintable shape or effect | A new Primitive in frus-core/src/scene.rs, handled in frus-gpu/src/painter.rs |
| Change how things are measured or positioned | frus-layout, or the widget's style() |
| Change text shaping or metrics | frus-text |
| Add a design token / theme knob | frus-widgets/src/theme.rs — and make sure the widget reads it instead of a literal |
| Handle a new gesture | frus-shell/src/gesture.rs, plus a registry in frus-widgets/src/ui.rs |
| Add an async capability (network, storage) | frus-shell behind a Cargo feature, exposed as a Command |
| Support a new platform | A new #[cfg] module in frus-shell and an arm in main!. Nothing else should need to change — if it does, that's a bug in the layering. |
| Add a shared type used by more than one crate | frus-core |
Deliberate constraints
Things that look like limitations and are in fact choices:
- No retained widget tree between frames. The tree is rebuilt from
view— or from the root component — and identity is carried by position and bykey(). What a component keeps is held beside the tree, in the runtime's state store, and found again by that identity;set_stateasks for a rebuild, it does not patch anything. This costs rebuild work. If it becomes real, the fix is memoization, not a mutable tree. Box<dyn Widget<Msg>>everywhere. Dynamic dispatch is a real cost, and it's what makes heterogeneous children and a stable trait boundary possible. Measured, it has not been the bottleneck.- One renderer backend.
wgpucovers Vulkan, Metal, DX12, and WebGPU. There is no software fallback, and adding one is not currently a goal. - Layout is taffy, text is cosmic-text. These are hard, well-solved problems. We wrap them; we don't reimplement them.
unsafeonly at the platform edge. The framework core is safe Rust.
Reading the milestone notes
276 numbered documents in docs/, one per milestone, each recording the objective, the alternatives weighed, the decision, the implementation, how it was verified, and what was deliberately left out.
When you're about to ask "why on earth is it done this way?" — grep docs/ first. The answer is usually there, along with the option that was rejected and the reason. They are currently written in French; translating them is a genuinely valuable contribution.
Useful entry points:
milestone-0.md— the GPU context and the very first framemilestone-129.md,milestone-131.md— the web target, and shrinking the wasm payloadmilestone-267.md,milestone-268.md— the single entry point and the facade cratemilestone-270.md–milestone-275.md— async effects,fetch,RemoteData, typed JSON