Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Retained UI

Live Demos: Retained UI | Widget Gallery

The retained UI is a tree of widgets stored as ECS entities. Every widget is an entity with components for layout, content, color, interaction, and (where applicable) widget-specific data. The tree is built once at startup and persists across frames. Per-frame work is reacting to events and mutating components. Layout, hit testing, and rendering run inside the retained-UI sub-schedule.

Two construction surfaces are available. UiTreeBuilder and UiNodeBuilder give explicit control over every component. The Ui scope, opened with tree.build_ui(parent, |ui| { ... }), wraps common widget shapes in one-call shortcuts. The scope is sugar over the builder and the two compose freely.

Enabling retained UI

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    world.resources.retained_ui.enabled = true;
    let camera = spawn_ortho_camera(world, vec2(0.0, 0.0));
    world.resources.active_camera = Some(camera);

    let mut tree = UiTreeBuilder::new(world);
    // ... build widgets ...
    tree.finish();
}
}

tree.finish() is the canonical end-of-build call. The retained-UI schedule runs every frame after that.

Two construction styles

Low-level builder

Each tree.add_node() returns a UiNodeBuilder that takes a chain of setters. The chain ends in .entity(), which produces the constructed Entity. Parent scope is managed with tree.in_parent(entity, |tree| { ... }). The closure form pushes the entity as the active parent for the body and restores the previous parent on return, which makes mismatched push/pop impossible.

#![allow(unused)]
fn main() {
let mut tree = UiTreeBuilder::new(world);
let root = tree
    .add_node()
    .boundary(Rl(vec2(0.0, 0.0)), Rl(vec2(100.0, 100.0)))
    .fg(ThemeColor::Background)
    .entity();

tree.in_parent(root, |tree| {
    let card = tree
        .add_node()
        .size(100.pct(), 200.px())
        .card()
        .entity();
    tree.in_parent(card, |tree| {
        tree.add_node()
            .size(100.pct(), 24.px())
            .label("Hello", 16.0, ThemeColor::Text);
    });
});
tree.finish();
}

High-level scope

tree.build_ui(parent, |ui| ...) opens a Ui scope rooted at parent. Each widget call (ui.button, ui.label, ui.slider, and so on) returns the constructed Entity. Containers like ui.row, ui.column, and ui.collapsing_header accept a closure for their children.

#![allow(unused)]
fn main() {
let mut tree = UiTreeBuilder::new(world);
let root = tree.add_node().fill().entity();

tree.build_ui(root, |ui| {
    ui.heading("Settings");
    ui.separator();

    let volume = ui.slider(0.0, 1.0, 0.8);
    let muted = ui.toggle(false);

    ui.row(|ui| {
        ui.button("Cancel");
        ui.button("Apply");
    });
});
tree.finish();
}

The two styles mix. Drop into the low-level builder with ui.tree() when one node needs fine control, then return to the scope for the rest.

Sizing

A node's flow-child size is set with .size(width, height). Both arguments are Length values built from the LengthExt trait on numeric literals.

LengthMeaning
100.pct()100% of parent's content axis
24.px()24 logical pixels
1.5.em()1.5 times the current font size
50.vw() / 50.vh()50% of the viewport width or height
Length::AutoContent-driven, measured by the engine and fed back
#![allow(unused)]
fn main() {
.size(100.pct(), 24.px())                  // full width, fixed height
.size(200.px(), Length::Auto)              // fixed width, content height
.fill_width()                              // shorthand for size(100.pct(), 0.px())
.fill()                                    // shorthand for size(100.pct(), 100.pct())
.size_px(48.0, 48.0)                       // shorthand for size(48.px(), 48.px())
}

Absolute positioning (window-anchored or boundary-anchored layouts) still uses the unit constructors Ab, Rl, Rw, Rh, Em, Vp, Vw, Vh directly, often combined with +.

#![allow(unused)]
fn main() {
.window_at(Ab(vec2(16.0, 16.0)), Ab(vec2(220.0, 18.0)))
.window(Rl(vec2(50.0, 50.0)), Ab(vec2(120.0, 36.0)), Anchor::Center)
.boundary(Ab(vec2(20.0, 20.0)), Ab(vec2(-20.0, -20.0)) + Rl(vec2(100.0, 100.0)))
}

window_at defaults to Anchor::TopLeft. Pass window directly for a different anchor.

Theme-bound colors

Theme-aware colors track the active theme and crossfade smoothly when the user switches themes. They live on UiNodeColor-bearing entities and are set per-state.

#![allow(unused)]
fn main() {
node.fg(ThemeColor::Text)               // base color
    .bg(ThemeColor::Panel)              // alias for fg, semantic only
    .fg_hover(ThemeColor::TextStrong)   // hover state
    .fg_pressed(ThemeColor::TextStrong)
    .fg_focused(ThemeColor::Accent)
    .with_theme_border_color(ThemeColor::Border)
    .with_theme_effect_role(ThemeEffect::PanelEffect)
}

Raw (non-theme-tracking) colors use .color_raw::<S>(vec4). The _raw suffix flags theme-broken usage at the call site so it stands out in review.

#![allow(unused)]
fn main() {
.color_raw::<UiBase>(vec4(0.0, 0.0, 0.0, 0.5))    // backdrop tint
.color_raw::<UiHover>(vec4(0.7, 0.5, 0.0, 1.0))   // accent override
}

Roles live in ThemeColor: Background, BackgroundHover, BackgroundActive, Panel, PanelHover, Border, Text, TextDisabled, TextStrong, Accent, AccentHover, AccentActive, Success, Warning, Error, plus a handful more.

Composite shortcuts

#![allow(unused)]
fn main() {
.card()                              // rect + border + Panel bg + PanelEffect
.label(text, size, role)             // with_text + text_left + fg(role)
.rect(8.0)                           // with_rect with no border, transparent border
.fill_width()                        // size(100.pct(), 0.px())
.auto_height()                       // size(_, Length::Auto)
.text_left()/.text_center()/.text_right()
}

Use them when they fit. Drop to the explicit setters when they do not.

Pointer events default off

pointer_events defaults to false. Static labels, decorative rects, and panel children do not intercept clicks. Interaction is opt-in.

#![allow(unused)]
fn main() {
node.with_interaction()              // enables pointer_events automatically
    .with_tooltip("Save")
    .with_cursor_icon(CursorIcon::Pointer)
    .fg_hover(ThemeColor::TextStrong)
}

A node that blocks pointer events without an interaction component (the rare case is a modal backdrop) calls .with_pointer_events() directly.

Flow containers

A node becomes a flow container as soon as any flow setter is called on it.

#![allow(unused)]
fn main() {
.flow_vertical()              // direction: vertical, padding: 0, gap: 0
.flow_horizontal()
.padding(8.0)                 // pad inside the container
.gap(4.0)                     // gap between children
.align_main(FlowAlignment::Start | Center | End)
.align_cross(FlowAlignment::Start | Center | End)
.flow_wrap()                  // wrap to a new line when children overflow
}

Children take their main-axis size from their own .size(...) (or from flex_grow).

#![allow(unused)]
fn main() {
let row = tree.add_node()
    .fill_width()
    .flow_horizontal()
    .padding(0.0)
    .gap(12.0)
    .align_cross(FlowAlignment::Center)
    .entity();
tree.in_parent(row, |tree| {
    tree.add_node().size(60.px(), 18.px()).label("Volume", 14.0, ThemeColor::TextDisabled);
    let slider = tree.add_node().fill_width().flex_grow(1.0).entity();
    tree.in_parent(slider, |tree| { tree.add_slider(0.0, 1.0, 0.5); });
});
}

The "static label on the left, slot on the right" row is common enough that tree.label_row(label, label_width, body) is a one-call shortcut for it.

Reading widget state

Widget state is read directly from world resources via free functions.

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    if let Some(value) = ui_slider_value(world, self.volume_slider) {
        self.volume = value;
    }
    if ui_clicked(world, self.save_button) {
        self.save();
    }
}
}

Event-style consumption drains ui_events.

#![allow(unused)]
fn main() {
for event in ui_events(world).to_vec() {
    match event {
        UiEvent::ButtonClicked(entity) if entity == self.save_button => self.save(),
        UiEvent::SliderChanged { entity, value } if entity == self.volume_slider => {
            self.volume = value;
        }
        UiEvent::TextInputSubmitted { entity, .. } if entity == self.search => {
            self.run_search(world);
        }
        _ => {}
    }
}
}

UiEvent covers buttons, sliders, range sliders, drag values, toggles, checkboxes, radios, tabs, dropdowns, multi-selects, text submits, modal close results, virtual list interactions, context menu picks, command palette picks, drag-and-drop, and tile rearrangement.

Mutating widgets

#![allow(unused)]
fn main() {
ui_set_text(world, label_entity, "New label");
ui_set_visible(world, panel_entity, true);
ui_set_disabled(world, slider_entity, true);
ui_focus(world, search_input);
ui_progress_bar_set_value(world, progress_entity, 0.75);
ui_show_toast(world, "Saved", ToastSeverity::Success, 2.0);
ui_show_modal(world, dialog_entity);
ui_show_command_palette(world, palette_entity);
}

Text-cursor and text-content updates on input widgets use the dedicated helpers (ui_text_input_set_value, ui_text_area_set_value, and so on).

Named entities

Instead of carrying an Option<Entity> field for every widget that will later be mutated, register a name during construction and look it up later.

#![allow(unused)]
fn main() {
let progress = tree.add_progress_bar(0.0).named("loading_progress");
// ...elsewhere:
if let Some(progress) = ui_named_entity(world, "loading_progress") {
    ui_progress_bar_set_value(world, progress, 0.5);
}
}

Names are unique strings stored in RetainedUiState::accessibility.named_entities. They cost nothing to add and the registry stays small.

Container scoping

Three ways to enter and leave a parent. Pick whichever fits the call site.

UseAPINotes
Add many children to a single parent`tree.in_parent(entity,tree
RAII guard formlet _g = tree.children_of(entity);Restores on drop
Inside a node-builder chain`.with_children(child

The legacy tree.push_parent(entity) and tree.pop_parent() calls still exist. New code uses the closure forms.

Responsive layout

Three engine primitives drive responsiveness.

  1. The breakpoint resource at world.resources.retained_ui.viewport.breakpoint is recomputed each frame from the logical viewport width. Compact is below 720, Medium is 720 to 1024, and Wide is 1024 and above.
  2. Wrap-aware text measurement. Text nodes with .with_text_wrap() get measured against their available width during layout, so containers can size to fit them.
  3. Declarative responsive bindings. Per-entity overrides for size, boundary corners, and visibility, applied automatically when the breakpoint changes.
#![allow(unused)]
fn main() {
let sidebar = tree.add_node()
    .boundary(Ab(vec2(0.0, 0.0)), Ab(vec2(220.0, 0.0)) + Rl(vec2(0.0, 100.0)))
    .responsive_position_2_at(
        UiBreakpoint::Compact,
        Ab(vec2(64.0, 0.0)) + Rl(vec2(0.0, 100.0)),
    )
    .fg(ThemeColor::Panel)
    .entity();

let nav_label = tree.add_node()
    .label("Inspector", 14.0, ThemeColor::Text)
    .responsive_visible_at(UiBreakpoint::Compact, false)
    .entity();
}

ui_responsive_apply_system runs before layout each frame, captures the construction-time values as the wide baseline on first apply, and swaps to overrides as the breakpoint changes.

Layout primitives

Three base layouts are mutually exclusive on a node.

  • Boundary, .boundary(p1, p2). A two-corner box computed against the parent rect. The standard fill-with-padding pattern.
  • Window, .window(pos, size, anchor) or .window_at(pos, size). Anchored placement at an explicit position with an explicit size. Suited to absolute overlays.
  • Solid, .solid(aspect, mode, alignment). Aspect-ratio-fitted, useful for images.

A node that is a child of a flow container omits all three. It is positioned by the parent's flow logic and sized by .size(width, height).

Floating panels and docked panels

#![allow(unused)]
fn main() {
let palette = tree.add_floating_panel(
    "command_palette",
    "Commands",
    Rect { min: vec2(40.0, 60.0), max: vec2(440.0, 380.0) },
);

let inspector = tree.add_docked_panel_right("inspector", "Inspector", 320.0);
let status_bar = tree.add_docked_panel_bottom("status", "", 24.0);
}

super::panel_content(tree, panel) returns the content child of a panel for appending into. Panel chrome (header, title, close button) is created automatically.

Dialogs and command palette

#![allow(unused)]
fn main() {
let dialog = tree.add_modal_dialog("Custom Modal", 380.0, 220.0);
let content = widget::<UiModalDialogData>(tree.world_mut(), dialog)
    .map(|d| d.content_entity)
    .unwrap();
tree.build_ui(content, |inner| {
    inner.heading("Are you sure?");
    inner.label("This cannot be undone.");
});
ui_show_modal(world, dialog);

let confirm = tree.add_confirm_dialog("Quit", "Save changes before quitting?");
// ConfirmDialog fires UiEvent::ModalClosed { entity, confirmed: bool } when dismissed.

let palette = tree.add_command_palette(8);
ui_command_palette_register(world, palette, "Save", "Ctrl+S", "File");
ui_show_command_palette(world, palette);
// Fires UiEvent::CommandPaletteSelected { entity, command_index }.
}

Layout units in detail

Position and size values are UiValue<Vec2> (or UiValue<f32> for a single axis), built from unit wrappers and combined with +.

UnitMeaning
Ab(value)Absolute pixels
Rl(value)Relative to parent, both axes, 0 to 100
Rw(value) / Rh(value)Relative to parent width or height only
Em(value)Multiples of the current font size
Vp(value)Viewport percentage, square
Vw(value) / Vh(value)Viewport-width or viewport-height percentage
#![allow(unused)]
fn main() {
Rl(vec2(100.0, 0.0)) + Ab(vec2(-32.0, 24.0))
}

Theming

Themes are first-class. Select the active theme, switch at runtime, drop in the built-in dropdown.

#![allow(unused)]
fn main() {
world.resources.retained_ui.theme_state.select_theme(2);
ui.theme_dropdown();
}

A 250 ms crossfade animates every theme-bound color when the active theme changes. Raw colors set through color_raw::<S>(...) do not track the change. That is the cost of opting out.

Frame schedule

The retained-UI sub-schedule (world.resources.schedules.retained_ui) runs once per frame and contains, in order: input sync, gamepad navigation, layout picking, widget interaction, event bubble, click events, layout state update, docked panel layout, responsive apply, layout compute, theme transition tick, theme apply, color blend, transform blend, render sync. Apps do not call these manually. The main schedule's RETAINED_UI entry runs the whole sub-schedule.

Patterns to follow

  • Build the tree once in initialize, not per frame. UI state persists across frames.
  • Read state via free functions (ui_clicked, ui_slider_value). Do not hand-track.
  • Prefer .named(name) over an Option<Entity> field for cross-function lookup.
  • Prefer theme-bound colors (.fg, .bg, .fg_hover) over raw colors.
  • Use tree.in_parent(entity, |tree| { ... }) for parent scope. The manual push and pop pair is legacy.
  • pointer_events defaults to false. Opt in with .with_interaction() (which enables them) on anything clickable.

See also

  • Automated UI Testing for UiTestDriver and integration-test patterns.
  • The widget gallery at apps/gallery for a comprehensive working example of every widget.