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:

body()describe what you wantframeworkmeasures and paintstap · swipe · buttonupdate(Message)the only place statechanges

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 screenA struct with body() and update(). Holds state; touches nothing else.
The runtimesrc/screen/runtime/. Drives one screen.
The view treeWhatever body() returned. Thrown away after each use.
The hostA 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:

  1. Read input. The runtime asks the host what happened — a tap, a drag, a button, nothing.
  2. Build the tree. It calls your body(). You return a fresh description of the screen; nothing is kept between frames.
  3. Measure it. Each view is offered a size and reports what it actually wants. Stacks divide space between children; a Spacer claims what is left.
  4. 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.
  5. Resolve. src/screen/routing.rs finds which declaration the touch landed in, scanning backwards so the innermost control wins.
  6. 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:

  • xpui compiles 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.

Widgetssrc/widgets/

TextOne line, measured with the backend's real font metrics
IconA backend asset chosen by role, not filename
IconToggleAn icon that shows a boolean and flips it
ImageA 1-bit bitmap you supply
List / ListRowRows drawn by the backend's own theme
SectionA titled group of anything
ToggleA boolean row reading On / Off
SliderA 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
ProgressBarDeterminate progress
DividerA one-pixel rule
ModalA centred option dialog that captures input while open

Layoutsrc/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 rootssrc/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.