Coordinates, responsive lengths, stacks, alignment, and scrolling for immediate-mode UI.
Hub: User interface · Controls: UI widgets
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 viasetScaleFactor— 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 viasetUiScale(default1) — 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.
world.ui.setUiScale(1.3);Call it once (e.g. from an entity's onStart); 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). 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:
.width = .auto // intrinsic or parent-driven default
.width = .{ .points = 240 } // fixed logical size
.width = .{ .percent = 0.5 } // half the available parent dimension
.width = .fill // all available parent spaceCombine a responsive length with minimum and maximum dimensions:
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. 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.
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", .{});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).
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.
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:
.stretchis.startfor 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
justifyand flex do. Declare a cross size if a block must hit-test precisely on its first frame. (flowaligns 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).
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:
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:
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_yis discrete; UI multiplies byScrollAreaStyle.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_motionsnaps. - 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 = trueby default). ImmediatescrollAreapaints 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.