# UI layout

Coordinates, responsive lengths, stacks, alignment, and scrolling for immediate-mode UI.

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

Stack/scroll APIs below exist on both host `world.ui` and game `hi.ui()` unless noted. Prefer `hi.ui()` from game modules; `setUiScale` / `setScaleFactor` / `applyAccessibility` are host `UiContext` (game scale uses `hi.ui().setScale`).

## Coordinate system

UI positions and sizes use logical points:

- the origin is the top-left corner;
- positive X points right;
- positive Y points down;
- widget dimensions are floating-point logical values independent of display density.

`UiMetrics` exposes `viewport_pixels`, `viewport_points`, `scale_factor`, and `safe_area`. The engine supplies the native display scale to the world and converts physical mouse input into logical coordinates. The context converts final logical rectangles into normalized device coordinates when it emits vertices.

This keeps controls approximately the same physical size on normal and high-DPI displays. Applications may set safe-area insets through `setSafeArea`; root stacks and modals respect them automatically.

### Global UI scale

`scale_factor` (the points-per-pixel divisor) is the product of two independent inputs:

- `display_scale`, set via `setScaleFactor` — the platform's own DPI/backing scale. The engine sets this automatically at device init and on resolution/monitor changes; application code normally never touches it.
- `ui_scale`, set via `setUiScale` (default `1`) — the actual "make the UI bigger" knob, layered on top. `setUiScale(1.3)` renders every control, glyph, and layout dimension at 130% of its normal size, independent of whatever DPI scale the display already applies.

```zig
world.ui.setUiScale(1.3);
```

Call it once (e.g. from an component's `start`); it composes correctly no matter when `setScaleFactor` is subsequently called, so a later monitor/DPI change never clobbers the preference. This is the mechanism for an accessibility text-size setting, a "UI scale" slider, or a game simply wanting a bigger default HUD.

### Accessibility preferences

`ui.AccessibilityPrefs` bundles `ui_scale`, `text_scale`, `density`, `high_contrast`, `always_show_focus`, and `reduce_motion`. Apply with `UiContext.applyAccessibility` / retained `RetainedUi.applyAccessibility` (theme + env + scale). High contrast uses `Theme.withHighContrast()` over the base palette. `always_show_focus` keeps focus rings visible after mouse focus (`showsFocusRing()`), not only after keyboard/gamepad navigation. `reduce_motion` snaps UI transitions (`hi.motion.resolve`) and freezes editor spinners.

### Motion

Shared tween/spring/transition values live in `hi.motion` (see [Motion Kit](/docs/systems/motion-kit.md)). Store drivers on entity/subsystem state; sample into styles each frame. Do not hang timelines on `UiContext`.

## Responsive lengths and constraints

Stack, button, and modal dimensions use `ui.Length`:

```zig
.width = .auto                    // intrinsic or parent-driven default
.width = .{ .points = 240 }       // fixed logical size
.width = .{ .percent = 0.5 }      // half the available parent dimension
.width = .fill                    // what the parent has left on its main axis; its whole cross extent
```

Combine a responsive length with minimum and maximum dimensions:

```zig
var sidebar = world.ui.vStack(.{
    .width = .{ .percent = 0.30 },
    .min_width = 220,
    .max_width = 360,
});
defer sidebar.end();
```

Percentages and fill resolve against the parent stack's available inner size, or against the safe viewport for root stacks. Along the parent's **main axis** that is the remainder: a `.fill` panel after a 400-point sibling in a row gets the row's inner width minus 400 and the spacing, and a `.fill` body under a fixed header gets the column's remaining height, so "fixed column + fill" ends at the container's edge. It is what has been placed so far — a `.fill` child *followed* by siblings still takes everything, because an immediate layout cannot see what comes next; put the fixed siblings first, or give the flexible one a concrete size (`Layout.remaining` answers the same question from code). On the cross axis `.fill` is the whole extent. Constraints are applied after resolution. This makes layouts fluid without allowing controls to become unusably small or excessively wide.

## Stack layout

Widgets must be emitted inside a layout stack:

| API | Layout |
| --- | --- |
| `vStack` | Column (main axis down) |
| `hStack` | Row (main axis right) |
| `flow` | Wrapping row (shared `FlowCursor` with retained editor layout) |
| `zStack` | Overlay — children share the origin; paint order is declaration order |

Stacks accept an optional stable `id` (engine and game SDK). Justify/flex shifts are cached by id so hit-testing matches the drawn shift — name wrappers that participate in centring rather than hand-computing positions.

```zig
var column = world.ui.vStack(.{
    .id = "hud.column",
    .position = .{ 24, 24 },
    .width = .{ .points = 260 },
    .spacing = 8,
});
defer column.end();

world.ui.text("Graphics", .{});

var row = world.ui.hStack(.{ .height = .{ .points = 36 }, .spacing = 6 });
defer row.end();

_ = world.ui.button("Apply", .{ .variant = .primary });
_ = world.ui.button("Reset", .{});
```

```zig
var chips = world.ui.flow(.{ .width = .{ .points = 200 }, .height = .{ .points = 80 }, .spacing = 6 });
defer chips.end();
for (tags) |tag| _ = world.ui.button(tag, .{ .height = .{ .points = 22 } });

var badge = world.ui.zStack(.{ .width = .{ .points = 48 }, .height = .{ .points = 48 } });
defer badge.end();
// Host: world.ui.icon(ui.icons.folder, .{ .size = 32 });
// Game: hi.ui().icon(hi.ui_types.icons.folder, 32, .primary);
world.ui.text("3", .{ .role = .accent, .size = .caption });
```

A stack supports these style fields:

| Field | Meaning |
| --- | --- |
| `position` | Optional absolute top-left position. A root stack defaults to `{ 16, 16 }`; a nested stack begins at its parent's cursor. |
| `width`, `height` | Responsive `Length` values. Child content receives the resolved dimensions minus padding. |
| `min_width`, `min_height` | Lower bounds applied after responsive length resolution. |
| `max_width`, `max_height` | Upper bounds applied after responsive length resolution. |
| `spacing` | Space inserted after each child along the stack direction. Defaults to `8`. |
| `padding` | Uniform inset (all sides). |
| `padding_x`, `padding_y` | Optional axis overrides (`resolveInsets` / retained chrome share the same helper). |
| `flex` | Weighted free-space claim when this stack is nested in a parent (same reflow path as `flexSpacer`). |
| `surface` | Optional semantic background; requires explicit width and height. |

Stacks are scoped and may be nested up to 16 levels. Always close them in reverse order. `defer stack.end()` is recommended; calling `end()` explicitly before the defer is safe because it is idempotent.

### Justify, cross-axis alignment, and flex

`justify` distributes leftover main-axis space across a stack's direct children: `.start` (default), `.center`, `.end`, `.space_between`, `.space_around`. Editor retained stacks use the same enum as `Distribution` (`ui.Justify`).

```zig
var actions = world.ui.hStack(.{ .height = .{ .points = 36 }, .justify = .space_between });
defer actions.end();
_ = world.ui.button("Cancel", .{ .id = "dialog.cancel" });
_ = world.ui.button("Confirm", .{ .id = "dialog.confirm", .variant = .primary });
```

`cross_align` positions a child within the stack's cross axis when its resolved size is smaller than the container: `.start` (default), `.center`, `.end`, `.stretch` (forces the child to fill the cross axis regardless of its own declared size). Retained `Align` is an alias of this enum. Leaves follow it too, text included: a label with the default `align_x = .start` is placed by the column's `cross_align` like the icon above it, while a label given an `align_x` aligns inside its own box instead and ignores the column's rule, so nothing is aligned twice.

It applies to **every** child: controls, nested stacks, and layout leaves (text, sprites, rects, bars) alike, so a row of mixed content lines up on one centre line. Two consequences of single-pass layout:

- **`.stretch` is `.start` for anything with an explicit size.** A leaf cannot be stretched, so a stretched row leaves it at the pen — which is why `.stretch`, the default for most rows, never moves content.
- **A content-sized child is placed when it closes, not when it opens.** Its cross extent does not exist at push time, so the shift is applied at the end of the stack, once its content has measured itself. Geometry is right on the first frame; the *hit* rects inside it pick the shift up on the next one, exactly as `justify` and flex do. Declare a cross size if a block must hit-test precisely on its first frame. (`flow` aligns at push time only: its cross axis belongs to each wrapped line.)

`flexSpacer(weight)` claims a weighted share of a stack's leftover main-axis space without drawing anything. Buttons and nested stacks can also set `.flex = weight` so free space is distributed after them (translate-only reflow — widgets do not grow their drawn size).

```zig
world.ui.text("Unsaved changes", .{ .role = .secondary });
world.ui.flexSpacer(1);
_ = world.ui.button("Save", .{ .id = "editor.save", .variant = .primary });
```

Geometry for `justify`/`flex` is repositioned by translating already-emitted vertices once every child's size is known (at the end of the stack). Hit-testing therefore reads the *previous* frame's resolved shift, which is exact once a layout has rendered one frame. Give a nested stack an explicit `.id` if it sits inside a justified/flexed parent and holds interactive children — but **never the same id string as a control inside it**: widget shifts and stack shifts share one cache (under separate keys), and reusing the string across the two is a naming collision waiting to be re-introduced. A stable tree gets correct structural ids for free.

### Scrolling and clipping

A stack with `.clip = true` and an explicit width and height crops all descendant geometry (rectangles, borders, glyphs) to its bounds; nested clips intersect with their ancestor's. `scroll_offset` shifts a stack's content along its main axis before clipping, giving a scrollable region:

```zig
var list = world.ui.vStack(.{
    .width = .{ .points = 240 },
    .height = .{ .points = 160 },
    .clip = true,
    .scroll_offset = scroll_position,
});
for (items) |item| world.ui.text(item.label, .{});
const max_scroll = @max(0, list.contentSize()[1] - 160);
list.end();
scroll_position = std.math.clamp(scroll_position + wheel_delta, 0, max_scroll);
```

`contentSize()` (call before `end()`) returns the stack's total child extent, letting callers clamp their own persisted `scroll_offset` each frame. For the common case, `scrollArea` owns the clipping, consumes native mouse-wheel input while the pointer is over it, and clamps a caller-owned offset when the area ends:

```zig
var files = world.ui.scrollArea("asset-files", &file_scroll, .{
    .width = .{ .points = 240 },
    .height = .{ .points = 160 },
});
defer files.end();
for (items, 0..) |item, index| {
    var id_buffer: [64]u8 = undefined;
    const id = std.fmt.bufPrint(&id_buffer, "asset.{d}", .{index}) catch item.label;
    if (world.ui.listItem(id, item.label, selected == index, .{})) selected = index;
}
```

The caller-owned offset keeps scroll state explicit and stable across immediate-mode frame rebuilding. Scroll input uses a shared kinematics model (`ui/scroll_physics.zig`) for both immediate `scrollArea` and retained editor chrome:

- **Line/notch wheels** (Windows mouse, macOS non-precise): `wheel_y` is discrete; UI multiplies by `ScrollAreaStyle.wheel_step` (or design-token default).
- **Precise trackpads** (macOS `hasPreciseScrollingDeltas`): layout points × `precise_gain` (no `× wheel_step`). Live samples paint 1:1; OS momentum is flagged separately.
- **Smoothing**: target/display pair; active user scroll is near-instant, coast eases. `reduce_motion` snaps.
- **Overscroll** (default on): Apple-style asymptotic rubber (`c ≈ 0.55`) while the user pulls; release velocity feeds an analytic underdamped spring. OS momentum cannot pin the peak or dig further out. Scrollbar thumbs stay on-track while content bands.
- Scrollbar thumbs paint when content overflows (`scrollbar = true` by default). Immediate `scrollArea` paints a vertical thumb; retained chrome paints the overflowing axis (or both). Thumb geometry is shared (`ui.scrollbarThumb` / `ui.scrollbarThumbHorizontal`). Retained thumbs can be dragged; immediate thumbs are display-only.
