-
Notifications
You must be signed in to change notification settings - Fork 9
Guide Composing UI
This page shows how to combine Yororen UI primitives into common
application layouts. Every code sample uses headless
factories + .render(cx) so the renderer is free to paint it
the default, brutalism, or your custom way — without code
changes in your app.
The four patterns below cover most real screens. Pick one and adapt it; the structure rarely changes between app domains.
The workhorse layout: a fixed-height title row at the top, a flex-grown content area below.
use gpui::{Context, IntoElement, ParentElement, Render, Styled, Window, div, px};
use yororen_ui::headless::button::button;
use yororen_ui::headless::heading::heading;
pub struct MyApp;
impl Render for MyApp {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Clone the entity once per on_* closure.
let entity = cx.entity();
let entity_for_save = entity.clone();
div()
.size_full()
.flex()
.flex_col()
// toolbar — fixed-height, padded, justify-between
.child(
div()
.h(px(44.))
.px_3()
.flex()
.items_center()
.justify_between()
.child(
heading(
"settings-title",
yororen_ui::headless::heading::HeadingLevel::H2,
"Settings",
cx,
)
.render(cx),
)
.child(
button("settings-save", cx)
.variant(yororen_ui::renderer::ActionVariantKind::Primary)
.on_click(move |_ev, _w, cx| {
entity_for_save.update(cx, |s, _cx| {
// … your save logic …
// s.save(cx);
});
})
.render(cx)
.child("Save"),
),
)
// content — flex_grow, padded, scrolls independently
.child(
div()
.flex_grow()
.p_3()
.id("settings-content")
.overflow_y_scroll()
.child(/* … your scroll body … */),
)
}
}Notes:
- The toolbar uses raw
div()because it's purely visual chrome. - The content sets
.id("…")(which convertsDivtoStateful<Div>) so.overflow_y_scroll()is reachable — it lives onStatefulInteractiveElement. - Every interactive primitive uses
.render(cx). The renderer paints bg / border / padding / radius / hover / active from the theme JSON. -
heading(id, level, text, cx)takes 4 args: id, level, text, and&mut App. The level comes before the text.
The v0.3 virtualization story: caller owns a
VirtualListController (a thin handle over gpui::ListState),
a headless virtual_list(id, &controller, cx) factory wraps
it, and the per-row closure emits whatever element you want
for each index. Stable identity comes from .id("row-{stable_key}").
use gpui::{Context, IntoElement, ParentElement, Render, Window, div, px};
use yororen_ui::headless::list_item::list_item;
use yororen_ui::headless::virtual_list::{
VirtualListController, virtual_list,
};
pub struct FeedView {
pub items: Vec<String>,
pub controller: VirtualListController,
}
impl FeedView {
pub fn new(_cx: &mut gpui::App) -> Self {
Self {
items: Vec::new(),
controller: VirtualListController::with_default(0),
}
}
pub fn set_items(&mut self, items: Vec<String>) {
self.items = items;
self.controller.reset(self.items.len());
}
}
impl Render for FeedView {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let controller = self.controller.clone();
let items = self.items.clone();
let entity = cx.entity();
virtual_list("feed", &controller, cx)
.row(move |ix, _w, cx| {
let entity_for_row = entity.clone();
// Stable per-row id — never use `ix` alone if items
// can be reordered; prefer a model id.
let id = format!("feed-row-{ix}");
let title = items.get(ix).cloned().unwrap_or_default();
list_item(id, title, cx)
.selected(false)
.on_click(move |_ev, _w, cx| {
entity_for_row.update(cx, |s, _cx| {
// … your row click logic …
});
})
.render(cx)
.into_any_element()
})
.render(cx)
}
}Notes:
- Use append-only data → key by index
(
format!("row-{ix}")). - Use mutable data → key by the row's primary key
(
format!("row-email-{}", email.id)). -
VirtualListController::with_default(n)starts withTopalignment and a 16-px overdraw — the typical default. -
uniform_virtual_listis the same idea for fixed-height rows (noon_visible_range_changesupport, but faster). -
list_item.on_clickisFn(&ClickEvent, &mut Window, &mut App)— three arguments.
When you have a modal that must paint above the page content
and respond to Escape reliably, build the scroll root, then add
the modal as a sibling — wrapped in
gpui::deferred(...).with_priority(2) so it sits above page
content but below toasts.
use gpui::{
Context, IntoElement, ParentElement, Render, Styled, Window, deferred, div,
};
use yororen_ui::headless::modal::{ModalCloseReason, ModalState, modal};
use yororen_ui::headless::button::button;
pub struct PageWithModal {
pub modal_state: gpui::Entity<ModalState>,
}
impl PageWithModal {
pub fn new(cx: &mut gpui::App) -> Self {
Self { modal_state: ModalState::new(cx) }
}
}
impl Render for PageWithModal {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let modal_state = self.modal_state.clone();
// Scroll root — owns everything that lives "in" the page.
let scroll_root = div()
.size_full()
.p_4()
.id("page-scroll")
.overflow_y_scroll()
.child(/* … page content … */);
// Modal lives as a sibling — same parent — so it doesn't
// get clipped by the scroll viewport and so Escape reaches it.
let modal = modal("page-modal", modal_state.clone())
.child(/* title */)
.child(/* body */)
.child(/* footer */)
.render(cx);
// Compose: scroll_root + modal (deferred).
div()
.size_full()
.child(scroll_root)
.child(deferred(modal).with_priority(2))
}
}Trigger open / close from anywhere in the app:
// Open
self.modal_state.update(cx, |s, _cx| s.open());
// Close (just hides, with the exit animation)
self.modal_state.update(cx, |s, _cx| s.close());
// Close with a reason (drives on_close callback)
self.modal_state.update(cx, |s, cx| {
s.invoke_close(ModalCloseReason::Programmatic, window, &mut *cx);
});Notes:
-
&mut *cxisDerefMutcoercion from&mut Context<App>to&mut App. It's required becauseinvoke_closeneeds&mut Appto schedule the animation tick. Inline it at the call site — never store in alet. - Priority 2 keeps modals above page content but below toasts. The toast host uses priority 3.
-
ModalState::new(&mut App)mints theEntity<ModalState>you'll own. Store it on your view struct (here,PageWithModal) — don't re-mint per frame.
A form inside a scrollable column is the most common
settings-page shape. The headless form factory owns field
values + errors + a submit callback; each form_field is a
labelled slot for an input.
use std::collections::HashMap;
use gpui::{
Context, IntoElement, ParentElement, Render, SharedString, Styled, Window, div, px,
};
use yororen_ui::headless::button::button;
use yororen_ui::headless::form::form;
use yororen_ui::headless::form_field::form_field;
use yororen_ui::headless::text_input::text_input;
pub struct SettingsPage {
pub email: String,
pub email_error: Option<String>,
}
impl Render for SettingsPage {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let entity = cx.entity();
let entity_for_form = entity.clone();
let entity_for_field = entity.clone();
let entity_for_save = entity.clone();
let form_el = form("settings-form", &mut *cx)
.value("email", self.email.clone())
.error("email", self.email_error.as_deref())
.submit("Save")
.on_submit(move |vals: HashMap<SharedString, String>, _w, cx| {
entity_for_form.update(cx, |s, _cx| {
if let Some(e) = vals.get("email") {
s.email = e.to_string();
s.email_error = if e.contains('@') {
None
} else {
Some("must contain @".into())
};
}
});
});
// Auto-generated submit button (Primary variant).
let submit_btn = form_el.submit_button(&mut *cx).expect("submit label was set");
let scroll_root = div()
.size_full()
.p_4()
.flex()
.flex_col()
.gap_3()
.id("settings-scroll")
.overflow_y_scroll();
let email_field = form_field("settings-email", "email", &mut *cx)
.label("Email")
.required(true)
.input(
text_input("settings-email-input")
.placeholder("you@example.com")
.on_change({
let entity = entity_for_field.clone();
move |new: &str, _w, cx| {
entity.update(cx, |s, _cx| s.email = new.to_string());
}
})
.render(cx, _w),
)
.render(&mut *cx);
scroll_root
.child(email_field)
.child(/* more form_fields */)
.child(
div()
.pt_2()
.flex()
.justify_end()
.child(
button("settings-save-btn", cx)
.variant(yororen_ui::renderer::ActionVariantKind::Primary)
.on_click(move |_ev, _w, cx| {
entity_for_save.update(cx, |s, _cx| {
// manual submit trigger
});
})
.render(cx)
.child("Save"),
),
)
}
}Notes:
- Each
moveclosure that touchesEntity<MyApp>clones it once at construction time. The closure owns the clone. - The submit button is auto-generated by the renderer
(Primary variant). Place it where it makes sense — typically
the last child of the scroll root, after every
form_field. -
form(id, cx)andform_field(id, name, cx)need&mut App. Inline&mut *cxat each call site — never store in alet. -
text_inputtakes 1 arg (id) and renders via.render(cx, window)— both are required.
A popover is the universal "anchor content next to a trigger"
shape. The trigger is whatever element you want; the content is
whatever element you want; the renderer floats the content
beside the trigger when state.is_open().
use gpui::{Context, IntoElement, ParentElement, Render, Window, div};
use yororen_ui::headless::button::button;
use yororen_ui::headless::dropdown_menu::{
DropdownItem, DropdownMenuItem, DropdownMenuState, dropdown_menu,
};
use yororen_ui::headless::popover::{PopoverState, popover};
pub struct UserMenu {
pub popover_state: gpui::Entity<PopoverState>,
pub menu_state: gpui::Entity<DropdownMenuState>,
}
impl UserMenu {
pub fn new(cx: &mut gpui::App) -> Self {
Self {
popover_state: PopoverState::new(cx),
menu_state: DropdownMenuState::new(cx),
}
}
}
impl Render for UserMenu {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let popover_state = self.popover_state.clone();
let menu_state = self.menu_state.clone();
let popover_for_btn = popover_state.clone();
let menu_for_select = menu_state.clone();
// Wire the menu items + on_select once.
menu_state.update(cx, |s, _cx| {
s.set_items(vec![
DropdownItem::Item(DropdownMenuItem::new("profile", "Profile")),
DropdownItem::Item(DropdownMenuItem::new("settings", "Settings")),
DropdownItem::Separator,
DropdownItem::Item(DropdownMenuItem::new("logout", "Logout")),
]);
s.set_on_select(move |id: SharedString, _w, cx| {
match id.as_str() {
"profile" => { /* … */ }
"settings" => { /* … */ }
"logout" => { /* … */ }
_ => {}
}
// Close the popover after pick.
popover_state.update(cx, |s, _cx| s.close());
});
});
// Trigger: a button that toggles the popover.
let trigger = button("user-menu-btn", cx)
.on_click(move |_ev, _w, cx| {
popover_for_btn.update(cx, |s, _cx| s.toggle());
})
.render(cx)
.child("User ▾");
// Content: a dropdown_menu driven by the menu_state.
let content = dropdown_menu("user-menu", menu_for_select.clone()).render(cx);
// Popover composes trigger + content.
popover("user-menu", popover_state)
.trigger(trigger.into_any_element())
.content(content)
.render(cx)
}
}Notes:
- The trigger's click handler toggles the popover. The popover's
dismiss_on_outside_clickanddismiss_on_escape(both default totrue) handle closing. -
dropdown_menu.trigger(t: AnyElement)requires anAnyElement.button(...).render(cx)returns aStateful<Div>— call.into_any_element()to convert. -
DropdownItem::Item(DropdownMenuItem)/Separatorare the variants; there's no separatemenu_itemfactory. -
set_on_selectisFn(SharedString, &mut Window, &mut App)— the id comes in as aSharedString, not&str.
- Guide-Recommended-Practices — the rules every real app converges on
- Guide-Recipes-ListTreeRow — the disclosure + virtual_list pattern
- Guide-Recipes-Settings-Form — full settings form with all 7 text inputs
- Guide-Recipes-VirtualList-Search — search box + virtualized results with lazy-loading
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.