The framework
On this page
How a frame runs
Three conversations, each one way; then what actually happens between them, in order; then what the crate holds. It is short because the design is small.
The three conversations
your screen xpui the firmware
┌───────────────────┐ ┌──────────────────┐ ┌──────────────────────┐
│ │ │ │ │ │
│ body() ────────┼──▶│ view tree ├──▶│ Canvas paint │
│ │ │ measure │ │ TextMetrics sizes │
│ │ │ render │ │ Chrome theme │
│ update(msg) ◀────┼───┤ routing │◀──┤ InputSource touch │
│ │ │ │ │ Clock time │
└───────────────────┘ └──────────────────┘ └──────────────────────┘
messages the View trait the Host traits
1. Your screen and xpui talk in messages. body() hands over a description
of the screen. When something happens, xpui hands back a message and calls
update(). Your screen never reads a touch, never asks where anything is on
screen, and never asks for a repaint — it only ever receives a message and
changes its own state.
2. xpui and the view tree talk through the View trait. Every widget
answers four questions: how big are you, where do you sit, what do you draw, and
what can be touched. Widgets declare their touchable regions; nothing polls
for input.
3. xpui and the backend talk through the Host traits. xpui cannot
draw, measure text or read a button. It says what it needs and a backend
provides it. This is why the crate has no dependencies, and why nothing inside
it names a product, a screen, an asset or a drawing library.
The loop a screen sits in is the whole of the contract:
The whole frame is: build the tree, measure it, collect what is touchable, find the message for whatever the user did, apply it, paint. The rest of this page is that sentence, unfolded.
The cast
| Your screen | A struct with body() and update(). Holds state; touches nothing else. |
| The runtime | src/screen/runtime/. Drives one screen. |
| The view tree | Whatever body() returned. Thrown away after each use. |
| The host | A backend. Paints, measures, reads input. |
The runtime is the only part that talks to everyone. Your screen and the backend never meet.
A frame with a touch in it
The application calls into the runtime once per loop:
- Read input. The runtime asks the host what happened — a tap, a drag, a button, nothing.
- Build the tree. It calls your
body(). You return a fresh description of the screen; nothing is kept between frames. - Measure it. Each view is offered a size and reports what it actually
wants. Stacks divide space between children; a
Spacerclaims what is left. - Collect what is touchable. Each view declares rectangles it responds to, along with the message to send. A slider declares its track, a row declares itself, a label declares nothing.
- Resolve.
src/screen/routing.rsfinds which declaration the touch landed in, scanning backwards so the innermost control wins. - Deliver. The runtime calls
update()with that message. You change your state. The runtime asks for a repaint.
Painting is the same steps 2–4 followed by a render pass, because the tree no
longer exists — it was dropped at the end of the last frame.
Why rebuild the tree every time
Because it removes a whole category of bug. There is no stored tree that can
disagree with your state, no "I changed the value but the screen still shows the
old one", no invalidation to get wrong. body() is a pure function of your
struct, so what you see is always what you hold.
The cost is real: body() runs on every repaint and on every frame of a drag,
and it allocates. That is the trade, made deliberately. Keep expensive work in
update(), where it happens once per event, rather than in body().
Messages, not callbacks
A widget does not run your code. It carries a value you gave it, and hands that value back:
Stepper::new(self.level).on_change(Msg::Set)
Msg::Set here is not a call — it is the constructor of an enum variant, used
as a function. When the track is dragged, the runtime builds Msg::Set(72) and
passes it to update().
This is why update() is the only place your state changes, and why you can
test a screen by calling update() directly with no UI at all.
Declaring instead of hit-testing
A view says what it responds to, never whether it was hit:
out.declare(rect, InputMask::TAP, Trigger::Message(msg));
The mask matters. A control that only accepts TAP never sees the frames while
a finger is held down, so it cannot be dragged by accident; a slider asks for
DRAG and does. The runtime handles focus, auto-repeat on a held button and the
minimum touch target, because every control declares the same way.
Where the backend fits
The runtime never calls the backend directly. It goes through the five traits in
src/host/ — Canvas, TextMetrics, Chrome, InputSource,
Clock — reached through small façades like Renderer::fill_rect(..).
A backend implements those five and installs the implementation once:
// Once, before anything is measured or drawn.
unsafe { xpui::host::install(&MY_BACKEND) };
Two consequences worth stating:
xpuicompiles with no dependencies and cannot name a backend symbol.- Tests install a fake host, so layout, input routing and widget behaviour are all testable on a laptop. That is how the suite runs with no simulator at all.
See host.md to implement one, and
xpui-backends for the
real ones.
The one piece of global state
The installed host. install() is called once at startup and only read
afterwards. It exists so a widget can write Renderer::fill_rect(..) instead of
threading a context parameter through every measure, render and
interactions call in the tree.
On the device the two callers run on different tasks, so the runtime installs it from both entry points rather than assuming which arrives first.
What the crate holds
Every public item is in reference.md; this is the shape.
Widgets — src/widgets/
Text | One line, measured with the backend's real font metrics |
Icon | A backend asset chosen by role, not filename |
IconToggle | An icon that shows a boolean and flips it |
Image | A 1-bit bitmap you supply |
List / ListRow | Rows drawn by the backend's own theme |
Section | A titled group of anything |
Toggle | A boolean row reading On / Off |
Slider | A track, moved by a drag, a tap or a key, over its own name and value |
Stepper | −, track and + as one control, over the same line |
ProgressBar | Determinate progress |
Divider | A one-pixel rule |
Modal | A centred option dialog that captures input while open |
Layout — src/layout/
vstack! and hstack! stack things with a gap. Spacer eats whatever space is
left, so a footer sits at the bottom without arithmetic. Padding, Frame,
Flexible and Tappable are chainable modifiers: Text::new("−").frame(44, 44).
ScrollView wraps content taller than the screen. It clips what overflows and
the runtime scrolls to keep the focused control visible — by swipe on a touch
panel, by Up/Down on a button one — so a screen never tracks a scroll position.
Screen roots — src/screen/
NavigationScreen is an ordinary page with a header and button hints, and takes
an .overlay() drawn above its content for dialogs. OverlayPanel is a
drop-down that leaves the screen beneath it intact, and can dim it with
Scrim::Dim.
Edit this page on GitHubIt lives in xpui-framework; a correction goes there.