Skip to content

Design Three Layer Architecture

MeowLynxSea edited this page Jun 18, 2026 · 5 revisions

3-layer architecture

Every other doc, every component page, every recipe assumes this picture.

theme JSON  ─▶  renderer (XxxRenderer)  ─▶  headless (XxxProps)  ─▶  gpui-ce
Layer Crate Role
Headless yororen-ui-core data + state + a11y + i18n. No visual decisions.
Renderer yororen-ui-default-renderer or yororen-ui-brutalism-renderer turns props into a styled div. One trait impl per component.
Theme a JSON file palette + design tokens the renderer reads by path.

The headless layer is the source of truth. The renderer is swappable. The theme is data.

What goes where

Concern Where Why
Click handler, focus wiring, keymap headless::XxxProps Same a11y contract for every renderer.
Caret, selection, IME, scroll, blink headless::text_input_core::TextInputCore Shared by all 7 text inputs + combo_box.
Open / close animation headless::XxxState::animation: AnimatedVisibility Each composite owns its own.
bg / fg / border / padding / radius / hover / active renderer Themable; the renderer is the only thing that reads cx.theme().
Palette + per-component geometry theme JSON Per-deployment, per-brand.
Notification queue, sticky flag, auto-dismiss notification::NotificationCenter App-level state, not visual.
RTL flip rtl + i18n::TextDirection Locale-driven layout adjustment.
cx.t("key.path") i18n::Translate for App Framework + app strings share one resolver.

The 55 components

The headless layer ships 55 factory functions, each with a marker type in yororen-ui-core::renderer::markers. The default renderer and the brutalism renderer each implement all 55 traits.

Button, ButtonGroup, IconButton, ToggleButton,
Label, Heading, Divider, FocusRing, Badge, Tag,
ProgressBar, Skeleton, Slider, Tooltip, Avatar,
Switch, Checkbox, Radio,
TextInput, TextArea, PasswordInput, NumberInput,
FilePathInput, SearchInput, Select, ComboBox,
Modal, Popover, DropdownMenu, Disclosure,
Toast, Notification, Panel, Card, Form,
ListItem, TreeItem, KeybindingInput, SplitButton,
EmptyState, Image, KeybindingDisplay, ShortcutHint,
Icon, Text, Spacer, Overlay, Menu,
FormField, RadioGroup, Table, Tree,
VirtualList, UniformVirtualList

Three consumption patterns

Every factory exposes the same three ways to consume its props:

API Returns What it does
props.apply(div) Stateful<Div> Sets id, focus tracking, click handler. No visual feedback.
props.render(cx) Stateful<Div> (or AnyElement for inputs / lists) Looks up the registered XxxRenderer, composes the visual, layers a11y on top.
Hand-roll a gpui::Element anything You paint; you still call props.apply(...) for a11y.

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 pixel: .apply(div()).child("Save"). You write the div; the headless layer only contributes the focus handle and the click handler.
  • Brand identity or bespoke animation: implement a custom XxxRenderer, register it once, the registry is global — every .render(cx) in the app picks up your visual.

The layers_demo puts all three side by side in one window.

When to swap the renderer

renderer::install(cx, cx.window_appearance()) is the default — modern rounded look, system-light.json or system-dark.json chosen by OS appearance. Three legitimate reasons to change:

If you want… Use
A custom theme, but the default look yororen_ui_default_renderer::install_with(cx, my_theme)
A completely different visual (sharp corners, hard shadows) yororen_ui::brutalism_renderer::install(cx) (feature-gated)
A custom renderer for one component cx.register_renderer_arc::<markers::Button, dyn ButtonRenderer>(Arc::new(MyButtonRenderer))

Swapping default_renderer::install for brutalism_renderer::install flips the entire app's look while leaving every headless call untouched. The same button("save", cx).on_click(...).render(cx) produces a different button — same state, same a11y, different pixels.

Live theme switching

Call yororen_ui::theme::install(cx, new_theme) inside a Render impl — every frame, or when the user picks a different palette. The renderer re-reads paths from the new Theme; nothing else needs to change.

Clone this wiki locally