Skip to content

Guide Composing UI

MeowLynxSea edited this page Jun 13, 2026 · 5 revisions

Composing UI (how to build real screens)

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.

1) Toolbar + content

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 converts Div to Stateful<Div>) so .overflow_y_scroll() is reachable — it lives on StatefulInteractiveElement.
  • 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.

2) List rows (virtual_list + list_item)

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 with Top alignment and a 16-px overdraw — the typical default.
  • uniform_virtual_list is the same idea for fixed-height rows (no on_visible_range_change support, but faster).
  • list_item.on_click is Fn(&ClickEvent, &mut Window, &mut App) — three arguments.

3) Modal-pinned layout

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 *cx is DerefMut coercion from &mut Context<App> to &mut App. It's required because invoke_close needs &mut App to schedule the animation tick. Inline it at the call site — never store in a let.
  • Priority 2 keeps modals above page content but below toasts. The toast host uses priority 3.
  • ModalState::new(&mut App) mints the Entity<ModalState> you'll own. Store it on your view struct (here, PageWithModal) — don't re-mint per frame.

4) Scrollable settings form

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 move closure that touches Entity<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) and form_field(id, name, cx) need &mut App. Inline &mut *cx at each call site — never store in a let.
  • text_input takes 1 arg (id) and renders via .render(cx, window) — both are required.

5) Popover with a menu body

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_click and dismiss_on_escape (both default to true) handle closing.
  • dropdown_menu.trigger(t: AnyElement) requires an AnyElement. button(...).render(cx) returns a Stateful<Div> — call .into_any_element() to convert.
  • DropdownItem::Item(DropdownMenuItem) / Separator are the variants; there's no separate menu_item factory.
  • set_on_select is Fn(SharedString, &mut Window, &mut App) — the id comes in as a SharedString, not &str.

See also

Clone this wiki locally