Hedronite · Frontend Dev · Rust · Mon 2026-10-19

GPUI 0.2 from zero shared state, a render cycle, and flexbox layout

Keep state in entities, call notify when it changes, and let each frame rebuild the elements from it.

Lesson Class: Dev (Rust desktop UI · GPUI)
Tested with: gpui 0.2.2 · taffy 0.9.0 · rustc/cargo 1.99.0 · macOS 27 (Apple silicon)
Lab: gpui_counter_lab · cargo test (9 tests, headless) · a real window via --smoke
Source: GPUI README, guides and 0.2.2 source · The Rust Programming Language, ch. 15.4–15.5 · Ratatui rendering docs
Handles, Not Borrows
Entity is an inert handle; update and read borrow only for the length of a call.
Notify Draws the Frame
A window re-renders when an entity it read during its last frame calls notify.
Rebuilt Every Frame
render returns a fresh element tree; Taffy lays it out as flexbox, then it is dropped.
Change the state, say so, and let the frame follow.

The problem

You want a small native desktop tool written in Rust. A counter with a button and a keyboard shortcut is the stand-in: tiny, but it needs everything a bigger tool needs. From web UI work you expect three things, and plain Rust hands you none of them.

First, state that several parts of the UI can share. Two widgets that both touch the same counter usually end up holding Rc<RefCell<Counter>> clones. Each clone can borrow the data at any moment from anywhere it is in scope, so the borrow rules move from compile time to run time, and a forgotten borrow surfaces as a panic far from the code that caused it.

Second, a screen that updates when that state changes. A hand-rolled redraw loop either redraws constantly, burning CPU while nothing happens, or misses a redraw after some code path changes a number.

Third, layout you can describe ("a fixed sidebar, and content that takes the rest") instead of computing pixel positions by hand. And you want all of it checked by an ordinary cargo test, without opening a window.

The idea, in plain words

GPUI is a UI framework for Rust from the team that builds Zed, a code editor. It keeps every piece of application state inside one App object and hands you typed handles to it. You change state through a handle, inside a short closure, and then call notify. Any window that read that state during its last frame re-renders by calling your render method, which builds a fresh tree of elements that GPUI lays out like CSS flexbox and paints on the GPU. Between frames the elements are thrown away; only your state persists. That is what the project means when it calls GPUI "a hybrid immediate and retained mode, GPU accelerated, UI framework".

Everything below was run with rustc and cargo 1.99.0 (edition 2024) and gpui 0.2.2 on an Apple-silicon Mac running macOS 27. Version 0.2.2, published on crates.io on 2025-10-22, was still the newest release when this was tested. The README says GPUI is pre-1.0, expects breaking changes between versions, and needs the latest stable Rust on macOS or Linux, so the example pins the exact version.

Concept A: entities and contexts

What they are

The App is the single top-level object that owns every model and view in a GPUI program. GPUI's contexts guide describes it as "the root context granting access to the application's global state. This context owns all entities' data." A context is the value you pass around to reach that state and GPUI's services; by convention the parameter is named cx.

An entity is a piece of state the App owns, and Entity<T> is the handle you hold to it. GPUI's "Ownership and data flow" guide calls the handle "merely an inert identifier plus a compile-time type tag" backed by a reference-counted pointer, and adds: "Much like an Rc ... this reference count is incremented when the handle is cloned ... but unlike an Rc it only provides access to the model's state when a reference to an App is available."

When you work on one particular entity, GPUI gives you a Context<T>. It dereferences into App, so everything on App still works, and it adds methods tied to that entity: notify (tell observers this entity changed) and emit (send a typed event).

Why it is built this way

The Rust book's Rc<T> gives you shared ownership, and cloning it "only increments the reference count". To mutate through the clones you add RefCell<T>, and then, in the book's words, "If you violate the rules, you'll get a panic! instead of a compiler error." The trouble is that every clone holder can start a borrow, and a borrow can live as long as the code holding it wants.

GPUI keeps the runtime check but narrows where a borrow can exist. The handle is inert, so a sidebar, a button, and a status bar can each keep a clone without borrowing anything. You reach the data only through a context. The &mut T that update lends lasts exactly as long as the closure you pass, and read has the signature fn read<'a>(&self, cx: &'a App) -> &'a T, so the shared reference borrows the context itself and the compiler will not let you keep it while you change anything else. Mutation happens at visible call sites, and no borrow outlives the call that made it.

Worked example

Here is the state of the counter app. The model is a plain struct, and the event type is a second plain struct.

pub struct Counter {
    pub count: usize,
}

pub struct Changed {
    pub by: usize,
}

impl EventEmitter<Changed> for Counter {}

impl Counter {
    pub fn increment(&mut self, cx: &mut Context<Self>) {
        self.count += 1;
        cx.emit(Changed { by: 1 });
        cx.notify();
    }

    pub fn increment_silently(&mut self) {
        self.count += 1;
    }
}

Counter knows nothing about windows or drawing. increment takes &mut self plus a Context<Self>, mutates the field, emits a Changed event, and calls notify. increment_silently is a deliberate bug kept for the diagnostics below: the same mutation with no notification. The empty impl EventEmitter<Changed> for Counter {} is the opt-in that allows Counter to emit Changed events; a type without it cannot be subscribed to.

The first test makes two handles to one entity, updates through one, and reads through both:

let a = cx.new(|_| Counter { count: 0 });
let b = a.clone();
a.update(cx, |counter, cx| counter.increment(cx));
let (via_a, via_b) = cx.read(|cx| (a.read(cx).count, b.read(cx).count));
a id=EntityId(1v1) b id=EntityId(1v1); count via a=1, via b=1

cx.new asks the App to create the entity and returns a handle. a.clone() copies the handle, not the counter, which is why both print the same EntityId. update lends its closure a &mut Counter and a Context<Counter>; read needs only a shared reference to the App. One increment through a is visible through b.

Observe versus subscribe

Other code can react to a change in two ways. To observe an entity is to register a closure that runs whenever that entity calls notify. To subscribe is to register a closure that receives each typed event the entity emits, which requires the EventEmitter impl above.

let _subscription = cx.update(|cx| {
    cx.observe(&counter, move |counter, cx| {
        log.borrow_mut().push(counter.read(cx).count)
    })
});
counter.update(cx, |counter, _| counter.increment_silently());
counter.update(cx, |counter, cx| counter.increment(cx));

cx.observe takes the entity to watch and a closure that receives the entity's handle plus a context; here the closure reads the current count and pushes it onto a log, a shared Rc<RefCell<Vec<usize>>> that the test prints afterwards. The first update moves the count to 1 without calling notify. The second moves it to 2 and notifies. The log after both:

observer saw [2]; final count = 2

The silent change produced no callback. The observer ran once, after the count was already 2, so observing tells you that something changed and you then read the current value. A subscriber gets the detail instead: three increment calls each emit Changed { by: 1 }, and a callback that adds up event.by prints events summed to 3. Use observe when the fact of a change is enough and subscribe when the event carries information.

Both calls return a Subscription, and its lifetime is the observer's lifetime. Dropping a Subscription unregisters the callback (its Drop impl calls the unsubscribe function). Calling .detach() keeps the callback alive, in the source's words, "until the entities it has been subscribed to are dropped". That makes Rust's let _ = a trap, because _ is not a binding and the value is dropped at once. The lab registers two observers, one bound to _kept and one to _, then increments:

observer bound to _kept ran 1 time(s); observer bound to _ ran 0 time(s)

Diagnostic: updating an entity from inside its own update

Symptom. The program panics:

thread 'nested_update_of_same_entity_panics' panicked at .../gpui-0.2.2/src/app/entity_map.rs:116:36:
cannot update gpui_counter_lab::Counter while it is already being updated

Cause. The test clones the handle and calls update on the clone inside the outer update:

let same = counter.clone();
counter.update(cx, |_outer, cx| {
    same.update(cx, |inner, _| inner.count += 1);
});

While update runs, GPUI takes the entity's state out of the App and leases it to your closure. A second update on the same entity finds the slot empty and calls GPUI's double-lease panic. This is the RefCell rule again, checked at run time because the two handles look independent to the compiler. The narrowing described above still helps: the panic points at two nested closures in one place, never at a borrow taken somewhere else long ago.

Fix. Use the &mut Counter you were already given (|outer, _| outer.count += 1) and drop the clone. If the second update is separate work, finish the outer update first and start the next one after it returns.

Concept B: views and the render cycle

What a view is

A view is an entity that can draw itself. The README puts it in one line: "A view is simply an Entity that can be rendered, by implementing the Render trait." The trait has one method:

fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement;

A Window represents one application window and has a root view. When GPUI draws a frame it calls render on that root view, which returns an element tree: nested UI building blocks (covered in Concept C) describing what the window should show right now.

Immediate mode, retained mode, and GPUI's hybrid

Ratatui, a Rust library for terminal interfaces, gives compact definitions in its rendering guide. In immediate mode, "the UI is recreated every frame" and "there's no permanent widget object in memory." In retained mode, "you set up your UI once, create widgets, and later modify their properties or handle their events." Ratatui is immediate: its counter tutorial says "Each iteration of the loop draws a single frame by calling Terminal::draw()", and your own loop decides when a frame happens.

GPUI splits the two. The element tree is immediate: elements are built from current state, laid out, painted, and then, per the element module docs, "before the start of the next frame, the entire element tree and any callbacks they have registered with GPUI are dropped and the process repeats." The entities are retained: they live in the App across frames, and views are entities. You never reach into a button to change its label, because the button is rebuilt from your state on the next frame.

Who decides a frame is needed

You do not write the loop. GPUI schedules a frame when something a window drew from has changed, and the mechanism is short enough to read in its source. Window::draw clears the App's set of accessed entities when a frame starts; every read or update during the frame adds that entity's id to the set; and at the end of the frame record_entities_accessed registers the window under each of those ids, replacing the previous frame's set. Later, App::notify looks up the windows registered for that entity and marks them dirty, and a dirty window is drawn again. An entity no window read only wakes its observers.

That is why the view below re-renders when a different entity notifies:

pub struct CounterView {
    pub counter: Entity<Counter>,
    pub focus_handle: FocusHandle,
    pub renders: usize,
}

impl CounterView {
    pub fn new(counter: Entity<Counter>, cx: &mut Context<Self>) -> Self {
        Self {
            counter,
            focus_handle: cx.focus_handle(),
            renders: 0,
        }
    }
}

impl Render for CounterView {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        self.renders += 1;
        let count = self.counter.read(cx).count;
        // ... element tree, shown in full in Concept C

CounterView holds a handle to the separate Counter entity, a focus handle (Concept C), and a renders field that exists only so the tests can count frames. The line that matters is self.counter.read(cx).count: reading through cx during render registers Counter with this window, so a later notify on Counter invalidates the window without any hand-written subscription.

The test counts renders around a silent change and a notifying one:

let initial = renders(cx);
counter.update(cx, |counter, _| counter.increment_silently());
cx.run_until_parked();
let after_silent = renders(cx);
counter.update(cx, |counter, cx| counter.increment(cx));
cx.run_until_parked();
let after_notify = renders(cx);

Before this snippet the test calls add_window_view, which opens a test window and returns the root view plus a VisualTestContext that acts as both a window and an App; renders is a small closure that reads the view's renders field. run_until_parked runs GPUI's queued work until nothing is left. With the test-support feature, GPUI draws every dirty window synchronously as soon as it finishes processing the notifications and events queued by an update, so the count is settled when the call returns:

renders: initial=1 after_silent=1 after_notify=2; count=2

Read it as a timeline: one render when the window opened, none after the silent change, one after the notify, while the count moved twice.

A real window behaves the same way. Running the binary with --smoke opens a 400 by 300 window on macOS, idles for 1.5 seconds, and prints the render count:

smoke: window opened, renders = 1

One render in 1.5 idle seconds: nothing changed, so nothing was drawn again.

Diagnostic: the label shows an old number

Symptom. The data is right and the screen is wrong. In the test, the count was 1 after the silent change and 2 at the end, yet after_silent equals initial. render never ran for the first change, so a real window would keep showing Count: 0.

Cause. No notify, no invalidation. A window is marked dirty only when an entity it read calls notify; writing a field is invisible to GPUI.

Fix. End every method that changes on-screen state with cx.notify(), and put the mutation and the notify in one method, as increment does, so callers cannot do one without the other.

Concept C: elements, layout and input

Elements, div, and Tailwind-style methods

An element is one building block of the tree. GPUI's docs say elements "are laid out according to the web layout standards as implemented by taffy". Taffy is a Rust layout library, and the standard used here is flexbox, the CSS model in which a container arranges its children along a row or column and divides the remaining space among them.

The element you use most is div(), an empty container that you style with methods named after Tailwind CSS utility classes; the doc comment on flex_1 links straight to the Tailwind docs. Sizes are often in rem, a multiple of the root font size. GPUI's default rem size is 16 px, so p_4 (padding) is 1rem or 16 px, and gap_2 is 0.5rem or 8 px.

div()
    .key_context("Counter")
    .track_focus(&self.focus_handle)
    .on_action(cx.listener(|this, _: &Increment, _window, cx| {
        this.counter.update(cx, |counter, cx| counter.increment(cx));
    }))
    .flex()
    .flex_row()
    .size_full()
    .bg(rgb(0x1e1e1e))
    .text_color(rgb(0xffffff))
    .child(
        div()
            .debug_selector(|| "sidebar".into())
            .w(px(120.))
            .h_full()
            .bg(rgb(0x2b2b2b)),
    )
    .child(
        div()
            .debug_selector(|| "content".into())
            .flex_1()
            .flex()
            .flex_col()
            .gap_2()
            .p_4()
            .child(
                div()
                    .debug_selector(|| "label".into())
                    .child(format!("Count: {count}")),
            )
            .child(
                div()
                    .id("increment")
                    .debug_selector(|| "button".into())
                    .w(px(80.))
                    .h(px(32.))
                    .bg(rgb(0x3366ff))
                    .child("+1")
                    .on_click(cx.listener(|this, _event, _window, cx| {
                        this.counter.update(cx, |counter, cx| counter.increment(cx));
                    })),
            ),
    )

This is the rest of render. The outer div fills the window and lays its children out in a row. The first child is a sidebar fixed at 120 px wide and full height. The second is the content area; flex_1 sets grow and shrink to 1 and the starting size to 0, so it takes all remaining width. Inside it, children stack in a column with an 8 px gap and 16 px of padding: a text label and an 80 by 32 px button holding "+1". The key_context, track_focus and on_action calls at the top belong to the keyboard section below. debug_selector tags an element with a name that a test can look up; it records anything only with the test-support feature and is an empty inline method otherwise.

Reading the layout back

With the window resized to 400 by 300, the test prints the bounds of each tagged element:

 sidebar: Bounds { origin: Point { x: 0px, y: 0px }, size: Size { 120px × 300px } }
 content: Bounds { origin: Point { x: 120px, y: 0px }, size: Size { 280px × 300px } }
   label: Bounds { origin: Point { x: 136px, y: 16px }, size: Size { 248px × 26px } }
  button: Bounds { origin: Point { x: 136px, y: 50px }, size: Size { 80px × 32px } }

Every number follows from the styles. The content area starts where the sidebar ends, at x = 120, and takes the other 400 - 120 = 280 px. The label sits 16 px inside it because of p_4, at (136, 16), and is 280 - 2 × 16 = 248 px wide. It is 26 px tall because GPUI's default line height is the golden ratio, 1.618, times the 16 px default font size: 25.9, rounded. The button's y is 16 (padding) + 26 (label) + 8 (gap) = 50.

Input, part 1: the mouse

A stateful element has an identity that GPUI can track between frames, which interaction needs because the element tree itself is rebuilt every frame. Calling .id("increment") turns a Div into a Stateful<Div>, documented as "Assign this element an ID, so that it can be used with interactivity", and on_click is only available on stateful elements.

A listener solves the next problem. GPUI's event callbacks have the shape Fn(&E, &mut Window, &mut App), which does not hand you your view. cx.listener(...) wraps your closure so that it receives &mut Self first. Its source shows it holds a weak handle to the view and calls update(...).ok(), so a registered callback never keeps a view alive, and a click that arrives after the view is gone does nothing instead of panicking.

The test reads the button's bounds, clicks its center twice, and reads the count:

clicked at Point { x: 176px, y: 66px }; count = 2

The click point is the button's center (136 + 80/2, 50 + 32/2). Both clicks ran the listener, which called increment, which notified, so the window also redrew.

Input, part 2: the keyboard

GPUI's key-dispatch guide states the split: "To expose functionality to the mouse, you render a button with a click handler. To expose functionality to the keyboard, you bind an action in a key context." An action is a user-defined struct naming a logical operation; actions!(counter, [Increment]) declares a unit struct named counter::Increment. A key context is a label attached to part of the element tree with key_context("Counter"), so a binding can apply only there. A focus handle, created by cx.focus_handle(), identifies something that can hold keyboard focus, and track_focus attaches it to an element. The binding ties key, action and context together:

cx.bind_keys([KeyBinding::new("up", Increment, Some("Counter"))]);

on_action(cx.listener(...)) in the render body says what happens when Increment reaches that element. Clicks and the up arrow both end in the same update call, so the two input paths cannot drift apart.

Diagnostic: the shortcut does nothing

Symptom.

count after 'up' unfocused = 0; after 'up up' focused = 2

Cause. GPUI dispatches a keystroke along a path that starts at the focused element and runs up through its ancestors. The dispatch tree is GPUI's record, built during each frame, of which elements declared key contexts, focus handles and action listeners. With nothing focused, focus_node_id_in_rendered_frame falls back to the root of that tree, so the path never passes through the div that declares the "Counter" context and the on_action listener.

Fix. Focus the element with window.focus(&focus_handle). The test does this before its second keystrokes, and main.rs does it when the window opens.

The second diagnostic is a compile error rather than a runtime surprise. Put on_click on a plain div and the build stops:

error[E0599]: no method named `on_click` found for struct `gpui::Div` in the current scope
 --> examples/pitfall_no_id.rs:8:27
  |
8 |         div().child("+1").on_click(cx.listener(|_this, _event, _window, _cx| {}))
  |                           ^^^^^^^^ method not found in `gpui::Div`

The message says on_click is missing from gpui::Div, and that is literally true: the method is defined on the trait for stateful elements, and only .id(...) turns a Div into one. The closure and the listener are fine; the receiver type is what the compiler rejects, which is why the caret points at on_click and not at the closure. Add .id("some-name") before .on_click(...), as the button does.

Putting it together

The project has one dependency. A Cargo feature is an optional switch a crate exposes at build time, and gpui needs two of them here:

[package]
name = "gpui_counter_lab"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]
gpui = { version = "=0.2.2", features = ["runtime_shaders"] }

[dev-dependencies]
gpui = { version = "=0.2.2", features = ["runtime_shaders", "test-support"] }

=0.2.2 pins the exact version, which matters for a pre-1.0 crate. test-support, listed only under dev-dependencies, provides TestAppContext, VisualTestContext and working debug_selector tags. Because the test build and the binary build ask for different feature sets, Cargo compiles gpui once for each; building the binary after the tests still recompiled gpui and the dependencies whose features changed. runtime_shaders is explained in the setup diagnostic below. The entry point:

use gpui::{App, Application, Bounds, WindowBounds, WindowOptions, prelude::*, px, size};
use gpui_counter_lab::{Counter, CounterView, bind_keys};
use std::time::Duration;

fn main() {
    // `--smoke` opens the window, waits 1.5 s, prints how many frames rendered, and quits.
    let smoke = std::env::args().any(|arg| arg == "--smoke");

    Application::new().run(move |cx: &mut App| {
        bind_keys(cx);
        let counter = cx.new(|_| Counter { count: 0 });
        let bounds = Bounds::centered(None, size(px(400.), px(300.)), cx);

        let window = cx
            .open_window(
                WindowOptions {
                    window_bounds: Some(WindowBounds::Windowed(bounds)),
                    ..Default::default()
                },
                |window, cx| {
                    let view = cx.new(|cx| CounterView::new(counter.clone(), cx));
                    window.focus(&view.read(cx).focus_handle);
                    view
                },
            )
            .unwrap();

        if smoke {
            cx.spawn(async move |cx| {
                cx.background_executor()
                    .timer(Duration::from_millis(1500))
                    .await;
                let renders = window.update(cx, |view, _, _| view.renders).unwrap();
                eprintln!("smoke: window opened, renders = {renders}");
                cx.update(|cx| cx.quit()).ok();
            })
            .detach();
        }
    });
}

Application::new().run starts the platform event loop and hands your callback a &mut App. The callback registers the key binding, creates the Counter entity, and opens a centered 400 by 300 window whose root view is a CounterView holding a clone of the counter handle; the same closure focuses the view so the up arrow has somewhere to go. The --smoke branch spawns a task on GPUI's executor that waits on a timer, reads renders through the window handle, and quits.

GPUI calls render for the first frame. Because render read the counter, the window now depends on it. A click on "+1" or a press of the up arrow runs a listener, the listener runs update on the counter, and increment bumps the count and calls notify. GPUI finds the window registered under that entity, marks it dirty, and calls render again; the old element tree and its callbacks are dropped, a new tree with Count: 1 is built, Taffy lays it out, and the GPU paints it. Each wish from the opening is covered: shared state behind inert handles with borrows scoped to closures, redraws driven by notify instead of a loop, and a layout whose numbers you can predict.

Setup diagnostic: building on a Mac without Xcode

On macOS GPUI draws with Metal, Apple's GPU programming interface, and the README says to install Xcode. On a Mac that has only the Command Line Tools, cargo build with gpui's default features fails:

error: [email protected]: metal shader compilation failed:
error: failed to run custom build command for `gpui v0.2.2`
...
  cargo::error=metal shader compilation failed:
  xcrun: error: unable to find utility "metal", not a developer tool or in PATH

Cargo exits with code 101. A shader is a small program that runs on the GPU, and gpui's build script precompiles its shaders by running xcrun -sdk macosx metal, a tool that ships with Xcode and not with the Command Line Tools. The script skips that step when the runtime_shaders feature is on, and the shader source is compiled when the app starts instead. So either install Xcode, as the README says, or enable runtime_shaders, as this project does. With it, the first test build compiled about 470 crates, gpui included, in 45.37 s on a 10-core Apple-silicon Mac; the binary then built in 13.54 s and the window opened.

Running the tests

Each test is marked #[gpui::test], which, per its docs, emits an ordinary #[test] so cargo test or cargo-nextest picks it up, and hands the function a TestAppContext. That context runs against GPUI's test platform, a stand-in for the operating system's windowing layer that simulates input such as clicks and keystrokes, so no real window opens:

$ cargo test -- --nocapture --test-threads=1
...
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

The nested-update test passes because it declares #[should_panic(expected = "while it is already being updated")].

Check yourself

  1. Two parts of your UI each hold an Entity<Counter>. Why can neither of them hold a borrow of the counter between frames, and what still panics at run time? Answer: the handle is inert. The &mut from update ends when its closure returns, and the &T from read borrows the context, so neither can be kept around. A nested update of the same entity still panics ("cannot update ... while it is already being updated").
  2. A method changes self.count, but the label keeps showing the old value. What is missing, and why does adding it fix the problem? Answer: cx.notify(). A window re-renders only when an entity it read during its last frame notifies; the render count stayed at 1 after the silent change.
  3. You register an observer with let _ = cx.observe(...) and it never fires. Why? Answer: _ drops the returned Subscription immediately, and dropping it unregisters the callback. Bind it to a named variable, store it in a struct, or call .detach().
  4. In the 400 by 300 window, why is the label 248 px wide and the button at y = 50? Answer: the content area is 400 - 120 = 280 px wide, and p_4 takes 16 px from each side. The button's y is 16 padding + 26 label height + 8 gap.
  5. Your up-arrow binding does nothing, but clicking works. Name two things the binding depends on. Answer: the focused element or one of its ancestors must declare key_context("Counter") and handle the action, and that element (or something inside it) must have focus; with nothing focused, dispatch starts at the root and never reaches that element.

Sources