Skip to content

Design Logic

MeowLynxSea edited this page Jun 17, 2026 · 4 revisions

Design logic

Components vs Widgets (still valid)

Yororen UI splits UI building blocks into two layers:

  • Components — primitives and composites that produce a single element on screen. They should be composable and predictable.
  • Widgets — containers / behaviors that orchestrate many elements: scrolling, virtualization control, higher-level layout.

This separation keeps Components small and reusable, while Widgets can focus on behavior and performance. In v0.3 the line is drawn mostly by what owns state: every Component factory in yororen-ui-core/src/headless/ returns a *Props builder; every Widget either owns the data layer (e.g. virtual_list owns a VirtualListController) or wraps gpui::Div directly.

Performance philosophy

  • Make the fast path the default. .render(cx) always paints a styled Div from the theme. No work for the caller.
  • Use virtualization for long scrolling content. See Design-Virtualization.
  • Ensure stable identity (.id(...)) for stateful elements, especially under virtualization. See Design-Keyed-State.
  • State goes in Entity<T>, not Arc<Mutex<T>>. gpui-ce tracks which entities a render closure reads; cx.notify() on the entity invalidates the window. See Composing UI.

Headless vs renderer (v0.3)

The single biggest v0.3 mental shift: every UI primitive is split into two crates — a headless data + state machine layer (yororen-ui-core) and a swappable visual layer (yororen-ui-default-renderer, yororen-ui-brutalism-renderer). See Design-Three-Layer-Architecture for the full picture.

The practical consequence for app code:

You want Use Crate
Default rounded look headless::button::button(...).render(cx) yororen-ui-core + yororen-ui-default-renderer
Brutalism look (sharp corners, hard shadows) same call, after brutalism_renderer::install(cx) yororen-ui-core + yororen-ui-brutalism-renderer
Full visual control headless::button::button(...).apply(div().bg(...)) yororen-ui-core only
A brand-new animation / look write a custom XxxRenderer, register via cx.register_renderer_arc::<m::Button, dyn ButtonRenderer>(...) both

The headless factory is the public contract. The renderer is a plug-in. A button is "a focusable, clickable thing with a label and an optional icon" — the factory says so; the visual is whatever the registered renderer chooses.

The three render pathways

Every headless factory exposes both .apply(div) and .render(cx) — they do very different things. A third pathway lets you hand-roll a custom element.

factory(id, cx) → XxxProps
   │
   ├── .apply(div) ─────────── Stateful<Div>   (a11y only)
   ├── .render(cx) ─────────── Stateful<Div>   (a11y + themed visual)
   └── .render(cx, window) ─── AnyElement      (text inputs only)
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.
(Custom) anything you build You write the painter yourself (see material_button in layers_demo)

When to use which

  • Default look, fastest path: .render(cx). The renderer paints bg / border / padding / radius / hover / active from the theme.
  • Caller controls every visual: .apply(div())...child("Save"). You write the div(); the renderer only contributes the focus ring and click handler.
  • Bespoke animation / brand identity: hand-roll a gpui::Element (the MaterialRippleElement in crates/yororen-ui-demos/layers_demo/src/material_button.rs is the canonical example). You still call props.apply(...) to keep the focus + click wiring.

The layers_demo puts all three in one window — read it to see them side by side.

Worked example — headless button

The full three-layer story for a single button, in one file:

use gpui::{div, App, Context, IntoElement, ParentElement, Render, Window};
use yororen_ui::headless::button::button;

pub struct MyApp;
impl Render for MyApp {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        // Path 1 — default look, fully painted by the renderer.
        let themed = button("save", cx)
            .caption("Save")
            .on_click(|_, _, cx| { /* ... */ })
            .render(cx);

        // Path 2 — caller-controlled visual, only a11y from the headless layer.
        let custom = div()
            .bg(gpui::red())
            .rounded(px(8.))
            .p_2()
            .apply(button("danger", cx).on_click(|_, _, cx| { /* ... */ }))
            .child("Delete");

        div().flex().flex_col().gap_2().child(themed).child(custom)
    }
}

Both calls go through yororen_ui::headless::button::button(...) (the factory). Path 1 delegates the visual to the registered ButtonRenderer (whatever was installed at boot). Path 2 keeps the visual with the caller; the headless layer only contributes the focus handle, click handler, and id.

The factory always returns a ButtonProps — there is no "themed button" vs "headless button" split in the type system. The choice is which terminal method you call: .apply(...) vs .render(cx).

Related pages

Clone this wiki locally