-
Notifications
You must be signed in to change notification settings - Fork 9
Guide Recommended Practices
Eight rules every v0.3 app converges on. They are not aesthetic
preferences — each one corresponds to a real failure mode in
the six live demos (counter, gallery_demo, inputs_demo,
layers_demo, theme_showcase, variant_showcase) or in the
headless factory contracts.
Every move closure that touches your app state needs its own
Entity<T> clone. The closure owns the clone; the original
entity handle stays in the global. A single
state.counter.clone() per closure is cheap (it's an Arc
bump).
// Counter — three closures, three clones.
let inc_entity = state.counter.clone();
let dec_entity = state.counter.clone();
let reset_entity = state.counter.clone();
button("dec", cx)
.on_click(move |_ev, _w, cx| {
dec_entity.update(cx, |c, cx| { c.value -= 1; cx.notify(); });
})
.render(cx)
.child("-")Don't try to share a single clone across multiple closures — the first one to consume it will move it out.
gpui-ce 0.3 does not auto-notify on Entity::update. The
&mut Context inside update is what triggers a re-render.
entity.update(cx, |state, cx| {
state.value += 1;
cx.notify(); // <-- required
});Without cx.notify(), the entity's data changes but the
window won't repaint.
For everything owned by the root Entity<MyApp> (most
apps), the canonical way to capture it inside an on_change /
on_click is:
text_input("email")
.placeholder("you@example.com")
.on_change({
let entity = cx.entity();
move |new: &str, _w, cx| {
entity.update(cx, |s, _cx| s.email = new.to_string());
}
})
.render(cx, window)Why cx.entity() and not state.global_clone():
- It's the only way to get an
Entity<MyApp>from inside aContext<MyApp>render closure. - Cloning it is cheap (
Entityis internallyArc). - Each
moveclosure that needs it must clone it once at construction time.
The inputs_demo has all seven text inputs wired this way. Copy
from crates/yororen-ui-demos/inputs_demo/src/inputs_app.rs.
Every stateful child in a list, virtualized view, or any tree that can be reordered needs a stable id derived from your data:
- Append-only data → use the row index as the key.
- Mutable data → use the row's primary key (id, uuid, path).
// Append-only feed — index is fine.
text_input(format!("row-email-{ix}")).render(cx, window)
// Mutable user list — use the user id.
list_item(format!("user-row-{}", user.id), &user.name, cx).render(cx)Without stable ids, gpui recycles element state across positions and your input focus / caret / selection get scrambled on every reorder.
A modal that lives inside a scrollable column will be clipped and won't receive Escape reliably. The shape is:
root
├── scroll root (.id("…").overflow_y_scroll())
│ └── page content
├── modal (wrapped in gpui::deferred(...).with_priority(2))
└── toast host (wrapped in gpui::deferred(...).with_priority(3))
The modal and toast host are siblings of the scroll root,
not children of it. Priority 2 keeps the modal above page
content; priority 3 keeps toasts above the modal (used by the
gallery_demo notification host).
Render closures must be pure with respect to gpui's redraw
cycle. The following belong in event handlers, not in
render:
- Spawning tasks (
cx.spawn) - File I/O
- Network calls
- Mutating any global that doesn't go through
entity.updatecx.notify()
- Calling
theme::install(cx, …)is legal (thetheme_showcasedoes it every frame) because it's idempotent and cheap.
For lists with more than ~50 rows, use virtual_list
(variable height) or uniform_virtual_list (fixed height,
faster). The demos render 10 000 items without breaking a
sweat.
Avoid rendering a single "giant item" that contains a huge subtree if you can split it into rows — virtualization clips what's offscreen, but it can't make a single visible item cheap.
Several headless factories need &mut App (to mint a fresh
FocusHandle or an Entity<XxxState>):
button(id, cx)label(id, text, cx)heading(id, level, text, cx)icon(id, source, cx)badge(id, text, cx)tag(id, label, cx)divider(id, cx)card(id, cx)panel(id, cx)-
switch(id, cx),checkbox(id, cx),radio(id, cx) empty_state(id, cx)disclosure(id, title, cx)list_item(id, title, cx)-
form(id, cx),form_field(id, name, cx) -
ModalState::new(&mut *cx),PopoverState::new(&mut *cx),SelectState::new(&mut *cx),ComboBoxState::new(&mut *cx),DropdownMenuState::new(&mut *cx),TooltipState::new(&mut *cx)
Inside Render::render you only have &mut Context<MyApp>.
Context<T>: DerefMut<Target = App>, so:
// Inside Render::render(_w, cx: &mut Context<MyApp>):
let modal_state = ModalState::new(&mut *cx); // <-- inline
let btn = button("save", cx).render(cx); // cx still borrowedInline the &mut **cx at the call site — never store it
in a let binding, because that ties up the borrow of cx and
blocks the next factory call that also needs &mut App.
Every headless factory exposes both .apply(div) and
.render(cx):
| API | What it returns | What it does |
|---|---|---|
props.apply(div) |
Stateful<Div> |
Sets id, track_focus, on_click. No visual feedback.
|
props.render(cx) |
Stateful<Div> (or AnyElement for inputs) |
Looks up the registered renderer, calls compose, then wires the same a11y callbacks on top. |
Default rule: use .render(cx). It picks up the active
renderer (default, brutalism, or your custom) and the active
theme.
Reach for .apply(div) only when:
- You need caller-owned visuals (no theme at all).
- You're writing a custom
gpui::Element(theMaterialRippleElementincrates/yororen-ui-demos/layers_demo/src/material_button.rsdoes this — it still callsheadless::button(id, cx).on_click(...).apply(...)internally to keep the focus + click wiring).
If you register MyButtonRenderer for the Button marker, the
previous TokenButtonRenderer is overwritten. There's no
layering. If you want a hybrid, write a new renderer that does
both.
theme_showcase swaps the theme (not the renderer) at runtime
via theme::install(cx, theme) — that's the cheap path for
"Next theme" toolbar buttons.
- Guide-Composing-UI — concrete patterns built from these rules
-
Guide-Animation —
AnimatedVisibilityand the composite lifecycle -
codex-skills/yororen-ui-state-inputs§8 — verifying input wiring -
codex-skills/yororen-ui-recipes§7 — composition rules
Yororen UI v0.3.0 · repository · Apache-2.0 · This wiki documents Yororen UI v0.3.0.
This wiki documents Yororen UI v0.3.0 — the headless-core, swappable-renderer build.