# UI widgets

Controls, focus navigation, modals, and stable IDs for immediate-mode UI.

Hub: [User interface](/docs/guides/user-interface.md) · Layout: [UI layout](/docs/guides/ui-layout.md)

Examples below use host `world.ui` (`UiContext`) unless noted. Game modules call `hi.ui()`. Tabs, numeric fields, sliders, rich text, world anchors, and virtual lists **are** on `UiApi`. Host-only: trees, splits, popups, DnD, custom SVG icons.

## Icons

**Game SDK** (`hi.ui()`): string catalog ids from `hi.ui_types.icons`, size, and text role:

```zig
const ui = hi.ui();
ui.icon(hi.ui_types.icons.save, 20, .primary);
ui.icon(hi.ui_types.icons.search, 20, .secondary);
ui.icon(hi.ui_types.icons.trash, 20, .destructive);
```

**Host `UiContext`:** `icon(Icon, IconStyle)` takes a static SVG `Icon` value (catalog under `ui.icons`, or a custom path model). Path commands match absolute `M`/`L`/`Q`/`C`/`Z` plus `line`/`circle`/`rect`/`polyline` — no XML, transforms, fills, or gradients. Custom SVG icons are not on `UiApi`.

## Progress, tooltips, context menus, drag-drop, wrapping text

```zig
world.ui.progress(load_fraction, .{ .width = .fill, .height = 6 });

if (world.ui.button("Save", .{ .id = "file.save" })) save();
world.ui.tooltip("Save the active scene", .{});

if (world.ui.beginContextMenu("hier.ctx", row_rect, .{ .width = .{ .points = 180 } })) |menu| {
    defer menu.end();
    if (world.ui.button("Rename", .{ .id = "hier.rename" })) rename();
    if (world.ui.button("Delete", .{ .id = "hier.delete", .variant = .destructive })) delete();
}

// Word-wrap (honors `\n`); width defaults to parent stack width.
world.ui.text("Long body copy that should reflow inside the panel.", .{
    .wrap = true,
    .role = .secondary,
    .size = .body,
});

// Drag payload: type tag + bytes. Payloads up to `drag_payload_inline_cap`
// (160 B — paths and ids) stay inline and cannot fail; larger ones allocate,
// which is the only case where `beginDrag` returns false. Accept on release
// over a rect.
if (dragging) _ = world.ui.beginDrag(asset_type_id, asset_path);
if (world.ui.acceptDrop(drop_rect, asset_type_id)) |path| placeAsset(path);
```

Context menus open on right-click over the given area (same popup layer as `beginPopup`). Editor hierarchy uses retained `contextMenu` (Focus / Duplicate / Delete). Tooltips use hover delay; wrapping uses shared `ui.text_layout`.

## Lists, dropdowns, and segmented controls

`listItem` is a focus-navigable full-width row. Its selected state uses the theme accent; unselected rows use the ghost button treatment. It works inside any stack, and combines naturally with `scrollArea` for long collections.

`selectBox` displays the selected label and opens a clipped option list in the popup layer. It returns `true` only when the selected index changes, and options reuse normal button focus and activation behavior:

```zig
const qualities = [_][]const u8{ "Low", "Medium", "High" };
if (world.ui.selectBox("graphics.quality", &qualities, &quality_index, .{})) {
    applyQuality(quality_index);
}
```

`SelectBoxStyle.max_visible_items` bounds the expanded list height; longer lists scroll with the mouse wheel.

`dropdown` is the editor-facing name for the same popup-backed selector and uses `DropdownStyle`. `segmented` keeps a small mutually-exclusive option set immediately visible, with an accent-filled active segment:

```zig
const transform_modes = [_][]const u8{ "Local", "Parent", "World" };
if (world.ui.segmented("transform.space", &transform_modes, &space_index, .{})) {
    updateTransformSpace(space_index);
}
_ = world.ui.dropdown("render.path", &paths, &path_index, .{});
```

## Popup overlays

`openPopup`, `beginPopup`, `closePopup`, and `isPopupOpen` provide floating editor overlays. A popup temporarily detaches from the current stack, ignores ancestor clipping, and its vertex range is moved above ordinary content during `finalizeFrame`. Only popup controls interact while it is open. Escape, gamepad B, or a pointer press outside the popup dismisses it without clicking through to the underlying control.

```zig
if (world.ui.button("Add", .{ .id = "component.add" })) {
    world.ui.openPopup("component-menu", world.ui.lastWidgetRect());
}
if (world.ui.beginPopup("component-menu", .{ .width = .{ .points = 220 } })) |popup_value| {
    var popup = popup_value;
    defer popup.end();
    _ = world.ui.button("Mesh Renderer", .{ .id = "component.mesh" });
}
```

`selectBox` uses this layer automatically, so opening a selector does not shift subsequent inspector rows.

## Text and numeric fields

Text fields keep storage explicit: pass a mutable byte buffer and its current length. They support mouse/keyboard focus, UTF-8 text input, insertion at the cursor, Backspace/Delete, Left/Right, Home/End, disabled and read-only states, and platform character capture. A focused editable field reports `wantsTextInput()` so the host can open a platform text-entry session (physical keyboard on desktop, native virtual keyboard on consoles).

```zig
if (world.ui.textField("entity.name", &name_buffer, &name_len, .{})) {
    previewEntityName(name_buffer[0..name_len]);
}
```

`numericField` builds on the same editor and updates a `f32` whenever its text parses successfully. `min`/`max` clamp accepted values; focused Up/Down or gamepad d-pad input applies `step`.

```zig
_ = world.ui.numericField("transform.position.x", &position.x, .{
    .step = 0.1,
    .min = -10000,
    .max = 10000,
});
```

Fields report per-frame changes. The editor/document layer remains responsible for grouping previews into one undoable command when editing is committed.

## Checkbox and toggle

Both controls mutate caller-owned booleans and return `true` on change. Checkbox styling inherits the active theme by default, while `corner_radius = 0` selects a square `addRect`/`addBorder` indicator instead of rounded primitives. Its optional checked/unchecked, border, and checkmark colors override individual theme tokens without replacing the theme. Use a checkbox for a property value, and a toggle for a persistent toolbar or mode state.

```zig
_ = world.ui.checkbox("entity.visible", "Visible", &visible, .{});
_ = world.ui.toggle("viewport.local", "Local", &local_space, .{});
_ = world.ui.checkbox("entity.locked", "Locked", &locked, .{ .corner_radius = 0 });
```

## Scoped composition, bindings, and edit results

`withVStack`, `withHStack`, `withScrollArea`, and `withSplitPane` accept compile-time callbacks and guarantee every scope ends. They create no closures or heap allocations:

```zig
world.ui.withVStack(.{ .spacing = 8 }, editor, struct {
    fn build(ui: *hikari.ui.UiContext, state: *EditorState) void {
        ui.text("Inspector", .{});
        _ = ui.checkboxBound("visible", "Visible", hikari.ui.bind(&state.visible), .{});
    }
}.build);
```

`view` adds a stable automatic ID scope around a reusable view function. Controls inside separate view instances may use short local IDs without collisions:

```zig
world.ui.view(entity.scene_id, entity, drawEntityInspector);

fn drawEntityInspector(ui: *hikari.ui.UiContext, entity: *EntityDocument) void {
    _ = ui.numericFieldBound("position.x", hikari.ui.bind(&entity.position[0]), .{});
}
```

For manual scopes, use `pushId`/`pushIdValue` with `defer scope.end()`. Composite controls derive their child IDs numerically, without formatting temporary strings.

`bind(&value)` creates a zero-cost typed pointer binding. `bindText(buffer, &len)` binds caller-owned text storage. Bound controls return a consistent `EditResult`:

- `changed`: preview value changed this frame;
- `committed`: accept the accumulated edit as one document command;
- `cancelled`: discard or revert the document preview.

Cancellation is reported rather than automatically restoring data because the authoritative value may belong to a `SceneDocument` command transaction rather than a raw field.

`propertyRow` standardizes inspector label/control alignment while letting the control remain a reusable compile-time callback:

```zig
const result = world.ui.propertyRow("Position X", .{}, &entity.position[0], struct {
    fn control(ui: *hikari.ui.UiContext, value: *f32) hikari.ui.EditResult {
        return ui.numericFieldBound("value", hikari.ui.bind(value), .{ .step = 0.1 });
    }
}.control);
```

## Tree views

`treeNode` separates disclosure from row selection and opens an indented scope when expanded. Expansion and selection remain caller-owned, which makes stable scene object IDs the natural widget IDs:

```zig
var node = world.ui.treeNode(entity.id, entity.name, &entity.expanded, selected_id == entity.id, .{});
defer node.end();
if (node.clicked) selected_id = entity.id;
if (node.open) {
    for (entity.children) |child| drawEntityNode(child);
}
```

The disclosure and row are independently focusable. Activating the disclosure toggles expansion; activating the row reports `clicked` without changing expansion. Tree views compose with `scrollArea` for large hierarchies.

## Split panes

`splitPane` creates horizontal or vertical panes with a draggable divider. The caller-owned ratio is clamped by `min_first` and `min_second` and can be persisted in editor workspace settings:

```zig
var split = world.ui.splitPane("editor.main", &main_split, .{
    .direction = .horizontal,
    .width = .fill,
    .height = .fill,
    .min_first = 180,
    .min_second = 260,
});
defer split.end();

drawHierarchy();
split.next();
drawViewportAndInspector();
```

Build first-pane content immediately after `splitPane`, call `next()` exactly once, then build second-pane content. `end()` calls `next()` automatically if necessary, allowing an intentionally empty second pane.

## Tabs

`tabs` draws a focusable tab strip and updates a caller-owned active index. Tabs can size naturally from their labels or share the available width with `equal_width`:

```zig
const inspector_tabs = [_][]const u8{ "Properties", "Components", "History" };
if (world.ui.tabs("inspector.tabs", &inspector_tabs, &active_inspector_tab, .{})) {
    persistInspectorTab(active_inspector_tab);
}
```

This first tab primitive covers selection and keyboard/gamepad activation. Close buttons remain higher-level editor behaviors. Editor dock tab strips (`components.tabStrip`) overflow with a framework horizontal scroller and drag between the left, right, and bottom docks (`toolkit/docking.DragSession` + `RetainedUi.reparent`). Tearing a tab clear of every dock opens a Class 1 tool window for that panel; a title-bar dock-back control returns it to the editor. Remount runs the panel's registration again (`fill` + `on_mounted`), so plugin/game surfaces keep their content.


## Text

```zig
world.ui.text("Connected", .{ .role = .accent });
world.ui.text("Connection lost", .{
    .role = .destructive,
    .scale = 1,
});
```

`TextStyle` supports a semantic colour `role`, an optional semantic `font` override, an explicit `color`, a glyph `scale`, and optional word-`wrap` / `max_width`. An explicit color overrides the role's theme color. Game SDK (`hi.ui()`) exposes the same fields.

```zig
// SDK: title scale without pushEnv, and a custom colour when needed.
ui.text("THOMAS", .{ .role = .primary, .size = .title, .scale = 2.2 });
ui.text("Danger", .{ .role = .destructive, .color = .{ 1, 0.4, 0.2, 1 } });
```

Games assign cooked TrueType assets to theme font slots at an asset-loading boundary. Text sizes select the matching `caption`, `label`, `body`, or `title` slot; controls use `label`. The `code` slot is available for explicit per-element use. An empty asset resets a slot to built-in Inter.

```zig
const poppins = hi.AssetRef.must(.font, "asset://./fonts/poppins/Poppins-Regular");
// Fails when the font is missing or not cooked; keep the built-in face then.
ui.setFont(.title, poppins) catch {};
ui.setFont(.body, poppins) catch {};
ui.warmFont(.body, "Příliš žluťoučký kůň"); // loading boundary, not frame drawing

ui.text("Theme title", .{ .size = .title });
ui.text("Forced title face", .{ .size = .body, .font = .title });
```

### Rect

Layout-leaf filled rectangle for scrims, fades, and solid colour plates. Prefer this over an empty stack with a surface:

```zig
ui.rect(.{
    .width = .fill,
    .height = .fill,
    .color = .{ 0, 0, 0, 1 },
    .opacity = 0.55,
});
// or theme role:
ui.rect(.{ .width = .fill, .height = .{ .points = 4 }, .surface = .accent });
```

The default UI font is pre-baked **Inter** for game HUD. Editor chrome defaults to **Geist** (Editor Settings → Appearance → UI font can switch back to Inter). Custom static `.ttf` files are validated and copied to `.shinfont` by Shinra, then parsed directly by the dependency-free runtime outline loader. Parsed fonts are typed AssetStore residents: every semantic slot owns a retained reference, identical assets share one parsed font, and the Residency panel reports them as `FNT` rows with their live reference count. Call `warmFont` at the same loading boundary for non-ASCII text so its outline cache never allocates during frame drawing. Font changes stay in the same flattened UI stream and do not add draw calls. Complex-script shaping is still outside this dependency-free path.

## Buttons

```zig
if (world.ui.button("Delete", .{
    .id = "save-slot.delete",
    .variant = .destructive,
    .disabled = !can_delete,
    .width = .{ .points = 140 },
})) {
    deleteSaveSlot();
}
```

A button returns `true` when the primary mouse button is released over the widget after having been pressed over the same widget, **or** when it holds keyboard/gamepad focus and Enter, Space, or the gamepad A button was just pressed. Disabled buttons neither hover, click, nor take part in focus navigation.

Available variants are:

| Variant | Plate | Outline |
|---------|-------|---------|
| `primary` / `destructive` | filled | none |
| `secondary` (the default) | filled surface | hairline |
| `ghost` | none at rest; wash on hover/press | none |

`ghost` is the chrome-less variant — that is what separates it from a secondary, and why `listItem`, `segmented`, `tabs` and a disclosure row do not box every unselected entry. `outline = true` boxes one deliberately; `fill = false` suppresses the plate *and* its hover/press wash, leaving a pure hit surface for a card that paints its own response (the focus ring still draws — it is a gamepad's only affordance).

`ButtonStyle` also supports explicit width and height, min/max width, horizontal padding, corner radius, label alignment (`align_x` — `.start` is what a menu row wants), text scale, and flex.

## Sliders

```zig
if (world.ui.slider("settings.ui-scale", &ui_scale, 0.75, 2.0, .{
    .step = 0.05,
})) {
    world.ui.setUiScale(ui_scale);
}
```

A slider clamps `value` to the supplied range and returns `true` when it changes. Drag anywhere on the control with the mouse to set it. Once focused, Left/Right or the gamepad d-pad step the value instead of moving focus away; Up/Down still perform spatial focus navigation. `SliderStyle` controls sizing, step size, track/thumb dimensions, disabled state, and optional colors. A non-positive step gives continuous mouse movement and uses one percent of the range for keyboard/gamepad stepping.

Game `hi.ui().slider(id, value, min, max, style: SliderStyle)` returns `SliderEdit` (`changed` / `committed`). Host `UiContext.slider` returns `bool` and also takes `SliderStyle`, not a positional step.

## Focus and keyboard/gamepad navigation

Every enabled button and slider takes part in focus navigation automatically — there is nothing to opt into. One widget is focused at a time (`ui.focused_id`):

- **Tab** / **Shift+Tab** move focus forward/backward through declaration order (the order widgets were called this frame), wrapping past the last/first — this matches web/ARIA tab order, which follows document order regardless of visual position.
- **Arrow keys** and the **gamepad d-pad** jump to the nearest widget in that direction (nearest by center-to-center distance, weighted toward candidates directly ahead over ones merely closer but off to the side). A focused slider consumes Left/Right for stepping while Up/Down continue to navigate.
- **Enter**, **Space**, or gamepad **A** activate the focused widget.
- **Escape** or gamepad **B** closes the open modal and restores whatever was focused before it opened.
- A visible focus ring is drawn only when focus changed via keyboard/gamepad — clicking a widget with the mouse gives it focus but hides the ring. This mirrors the web's `:focus-visible`: keyboard users get a clear indicator, mouse users don't get a ring they didn't ask for.
- Opening a modal traps focus inside it: Tab/arrow navigation while a modal is open only reaches that modal's own widgets, never the page behind it, matching the ARIA dialog focus-trap pattern.
- Nothing is focused until the user's first Tab/arrow/d-pad press — there is no auto-focus, on the page or inside a freshly opened modal. A mouse-only or controller-idle player never sees a ring they didn't ask for; the first press lands on the first (or, for Shift+Tab, last) focusable widget.

```zig
// No extra wiring needed — Tab/arrows/Enter/Space/gamepad already reach this:
if (world.ui.button("Save", .{ .id = "editor.save", .variant = .primary })) {
    save();
}
```

Resolving "what's next in tab order" or "what's nearest in this direction" needs every focusable widget's final position for the frame, which — like the `justify`/`flex` reflow above — isn't known until the widgets that declare it have actually been built. Navigation is therefore resolved once per frame, at the top of `beginFrame`, against the *previous* frame's finished registry of focusable widgets. In practice this means a freshly-appeared row of buttons (or a modal that just opened) needs one rendered frame before its first Tab/arrow press can reach it; a stable layout is exact from then on. There is currently no repeat-while-held for navigation keys (matching the rest of the input system, which is edge-triggered throughout) and no left-stick analog navigation, only the d-pad.

## Detecting the active input device

`ui.last_input_device` (an `InputDevice`: `.mouse`, `.keyboard`, or `.gamepad`) tracks whichever device produced the most recent activity, updated every frame with no restart or explicit mode switch needed — mouse movement or a click claims `.mouse`, any key edge claims `.keyboard`, and a gamepad button edge or a stick pushed past a small deadzone (so idle analog drift doesn't false-trigger it) claims `.gamepad`. This is the same primitive modern UI frameworks use to swap "Press A" for "Press Enter," or a gamepad-glyph icon for a keyboard-glyph one, the instant the player switches devices:

```zig
if (world.ui.last_input_device == .gamepad) {
    world.ui.text("Press A to continue", .{});
} else {
    world.ui.text("Press Enter to continue", .{});
}
```

There's no built-in icon/glyph set yet — this only exposes the signal to build that on top of.

## Spacing and panels

`spacer(size)` advances the current stack cursor without drawing anything.

`panel(rect, color)` draws an absolute colored rectangle. It does not participate in stack layout. `addRect` and `addText` are also public for low-level custom drawing, but reusable widgets should prefer the normal layout API where possible.

## Modals

Open a modal by stable ID, then describe it after the page's normal layout stacks have ended:

```zig
var page = world.ui.vStack(.{ .position = .{ 16, 16 }, .width = .{ .points = 200 } });
if (world.ui.button("Quit", .{ .variant = .destructive })) {
    world.ui.openModal("confirm-quit");
}

// End the page stack before beginning the modal.
page.end();

if (world.ui.beginModal("confirm-quit", .{})) |modal_value| {
    var modal = modal_value;
    defer modal.end();

    world.ui.text("Quit the game?", .{});

    var actions = world.ui.hStack(.{ .height = .{ .points = 36 } });
    defer actions.end();
    if (world.ui.button("Cancel", .{ .id = "confirm-quit.cancel" })) {
        world.ui.closeModal("confirm-quit");
    }
    if (world.ui.button("Quit", .{
        .id = "confirm-quit.accept",
        .variant = .destructive,
    })) {
        world.requestQuit();
    }
}
```

An open modal draws a full-viewport backdrop, centers its content panel, and blocks button interaction in the page behind it. Modal content uses the same stack, widget, theme, and stable-ID APIs as normal content.

`beginModal` must be called with no normal layout stack open. This ensures the modal is a root overlay rather than a child of the invoking panel. It returns `null` when the requested modal is closed. Only one modal is active at a time; opening another ID replaces the current modal.

`ModalStyle` controls responsive width and height, minimum and maximum dimensions, padding, spacing, backdrop color, and optional panel background. By default a modal uses 90% of the safe viewport width up to 420 points. It is always clamped to the safe viewport. Call `closeModal` with the same ID to dismiss it, and use `isModalOpen` when view logic needs to query its state.

## Stable widget identity

Immediate-mode widgets need a stable identity across frames to track pressed state. A button uses its title as its identity unless an explicit `.id` is supplied.

Always provide IDs when the same title can appear more than once:

```zig
_ = world.ui.button("Open", .{ .id = "toolbar.open" });
_ = world.ui.button("Open", .{ .id = "project-dialog.open" });
```

IDs should describe the widget's stable role, not its screen position or a translated label. Two live buttons with the same ID share interaction state and should be treated as an error in application composition.
