Skip to content

Ui

Retained-mode UI: author a panel/text/button tree once in a .ui file, load it, and render it each frame into a screen rectangle. Unlike the immediate-mode Gui (Nuklear, rebuilt every frame in code), a retained UI is data — a document you load, query, and mutate. The engine letterboxes the document’s design size into whatever rectangle you give it (aspect preserved, centered), so one layout scales cleanly across window sizes. Buttons dispatch clicks straight to your game as Game.<name>() calls.

Panels and images draw as textured quads; text labels reuse the Text (SDF) pass, so they stay crisp at any scale. The retained UI draws over the 3D scene and under the immediate-mode Gui overlay.

A static entry point. You do not construct Ui.

Returns: UiDocument — or null if the file is missing or fails to parse.

Parameters:

  • path (String) — Project-relative path to a .ui file in the mounted project.

Loads and validates a .ui document. Malformed input never crashes — a bad file logs its errors and returns null (validation happens at the parser boundary: duplicate ids, dangling/cyclic parents, and out-of-range values are all rejected or clamped).

import "engine" for Ui
var doc = Ui.load("ui/main.ui")
if (doc == null) System.print("UI failed to load")

Returns: List — the resolved content rect [x, y, w, h] in pixels (the letterboxed area the UI actually occupies), or null if doc is null.

Parameters:

  • doc (UiDocument) — The document to render.
  • x, y, w, h (Num) — The target screen rectangle in pixels (y-down, top-left origin).

Call this in Game.draw(). It resolves the layout, letterboxes the design into (x, y, w, h), runs the pointer interaction (hover/press states, and a button released inside fires Game.<onClick>()), and submits the draws. Pass the full window for a full-screen UI:

import "engine" for Ui, Window
class Game {
construct new() { _doc = null }
init() { _doc = Ui.load("ui/main.ui") }
draw() {
if (_doc != null) Ui.render(_doc, 0, 0, Window.getWidth(), Window.getHeight())
}
// Fired when the button with on_click = "onPlay" is clicked.
onPlay() { System.print("play!") }
}

Clicks are dispatched after draw() returns, from native engine context — never re-entrantly inside render. A handler named by on_click that your Game does not define surfaces a runtime error rather than failing silently.

Returns: UiElement — or null if there is no element with that id.

Parameters:

  • doc (UiDocument) — The document to look in.
  • id (String) — The element’s id from the .ui file.

Get a handle to a named element so you can read or change it at runtime.

A loaded document. Obtain one from Ui.load; pass it to Ui.render / Ui.get.

  • designWidth (Num) — the authored design width (px).
  • designHeight (Num) — the authored design height (px).
  • elementCount (Num) — number of elements in the document.

A non-owning handle to one element, from Ui.get. It stays valid only while its document is alive and unchanged structurally — check valid if you keep one across frames.

  • valid (Bool) — false once the document changed structurally or was freed.
  • id (String) — the element’s id.
  • visible / visible = (Bool) — is it drawn and hit-testable?
  • text / text = (String) — the label text (for text elements) or the current value (for an input field).
  • onClick = (String) — set a button’s handler name (dispatches to Game.<name>()).
  • x, y, width, height (Num) — the resolved rect (in design px), valid after Ui.render.
  • checked / checked = (Bool) — a toggle’s or radio’s state (a click sets it; poll it here).
  • value / value = (Num) — a slider’s or progress bar’s value in [min, max] (a drag sets a slider; poll it here — for a progress bar, set it here to move the fill).
var label = Ui.get(doc, "score")
if (label.valid) label.text = "Score: %(score)"
var volume = Ui.get(doc, "volume") // a slider
setVolume(volume.value)

A .ui file is TOML: one [document] table plus an [[element]] array. Elements reference their parent by id (parent = "" or omitted is a root). The layout model is Unity-uGUI-style: anchors are fractions [0..1] of the parent, offsets are pixels from the anchored corner.

[document]
design_width = 960 # the reference resolution the design is authored at
design_height = 540
[[element]]
id = "root"
type = "panel" # "panel" | "text" | "button"
anchor_min = [0.0, 0.0] # full-stretch to fill the parent
anchor_max = [1.0, 1.0]
offset_min = [0.0, 0.0]
offset_max = [0.0, 0.0]
color = [0.1, 0.11, 0.14, 1.0] # RGBA 0..1 (panel/button fill)
[[element]]
id = "play"
type = "button"
parent = "root"
anchor_min = [0.5, 1.0] # a point anchor (min == max) sized by the offsets below
anchor_max = [0.5, 1.0]
offset_min = [-90.0, -84.0]
offset_max = [90.0, -32.0]
on_click = "onPlay" # -> Game.onPlay()
normal_color = [0.24, 0.42, 0.78, 1.0]
hover_color = [0.30, 0.52, 0.92, 1.0]
pressed_color = [0.18, 0.32, 0.60, 1.0]
[[element]]
id = "playLabel"
type = "text"
parent = "play"
anchor_min = [0.0, 0.0]
anchor_max = [1.0, 1.0]
offset_min = [0.0, 0.0]
offset_max = [0.0, 0.0]
text = "Play"
font_size = 22.0
text_color = [1.0, 1.0, 1.0, 1.0]
text_align = "center" # "left" | "center" | "right"

Common element fields: id, type, parent, visible (default true), raycast_target (default true, false = transparent to clicks), the anchor_min/max + offset_min/max rect, and pivot (default [0.5, 0.5]).

Panel/button fill: color (RGBA); an image, referenced either by image = "ui/panel.png" (a mounted path — the friendly form, resolved on first render) or by texture (a raw renderer texture handle; 0 = solid color); and nine_slice (bool) + border = [L, R, T, B] (px) for 9-slice scaling. See Textured panels & 9-slice below.

Text (type = "text"): text, font_size (px), text_color, text_align.

Button (type = "button"): on_click (handler name → Game.<name>()), and the state tints normal_color / hover_color / pressed_color.

Toggle (type = "toggle"): a checkbox. checked (bool, default false), check_color (the inner fill when checked), color (the box), and an optional on_changeGame.<name>(). A click flips checked; the game reads it via element.checked.

Radio (type = "radio"): a grouped toggle. The same fields as a toggle — checked (bool, default false), check_color (the inner dot when selected), color (the box), an optional on_changeGame.<name>() — plus a group (string). Radios that share the same non-empty group are mutually exclusive: clicking one selects it (checked = true) and deselects its group siblings, and a radio never toggles back off. The game reads which one is selected by polling each radio’s element.checked (there is no group accessor — the grouping is authored in the .ui file).

Slider (type = "slider"): a horizontal slider. value in [min, max] (defaults 0.5 in [0, 1]), color (track), fill_color, handle_color, and an optional on_change. Dragging sets value; the game reads it via element.value. Bounds are clamped to a finite range on load.

Progress (type = "progress"): a display-only progress bar — no handle and no pointer interaction. It shares the slider’s value/range model: value in [min, max], color (the track), and fill_color (the fill drawn up to the normalized value). Drive it from script by setting element.value. This is the retained-UI progress element; for an immediate-mode bar rebuilt in code each frame, use the Gui instead.

Input (type = "input"): a focusable, editable single-line text field. text is the initial (and current) value; placeholder is a dimmed hint shown while the field is empty; color fills the field frame; caret_color tints the caret bar. Click the field to focus it (a caret appears) and type to edit — insert / backspace / delete and Left/Right/Home/End caret movement are all UTF-8-aware (whole codepoints), and a press elsewhere blurs it. Two optional handlers dispatch to Game.<name>(): on_change fires whenever the buffer changes, and on_submit fires on Enter. The game reads (and may set) the current value through the existing element.text accessor. This is the v1 field — selection, clipboard, multi-line, and IME composition are a documented follow-up (they do not work yet).

Canvas (type = "canvas"): a region (with a background color) the game draws custom primitives into with the Canvas API — the enabler for graph/diagram editors.

Unknown type values are dropped with a warning (forward-compatible); unknown fields are ignored.

By default every element is positioned by its own anchors/offsets. A container element can instead auto-position its direct children by flow — set layout on the container and the engine places each child in turn, so rows, columns, and grids lay themselves out instead of being hand-placed.

  • layout ("horizontal" | "vertical" | "grid") — the flow direction. Horizontal packs children left→right, Vertical top→bottom, and Grid places columns children per row then wraps to the next (each row is as tall as its tallest child). Absent (the default) leaves the container’s children on plain anchor positioning, unchanged.
  • spacing (Num, px) — the gap between adjacent items.
  • padding ([L, R, T, B], px) — insets the content origin. L/T seed the flow cursor; R/B are reserved for a future width-constrained wrap.
  • columns (Int, Grid only, ≥ 1) — how many items fill a row before it wraps.

A layout group sets only each child’s position — the child keeps its own size (from its offset_min/max). Children of a flow-positioned element still anchor relative to that element’s resolved rect, so layout groups compose with the anchor system: a grandchild can centre itself inside a flow-placed cell. Adding layout is additive — a .ui with no layout field loads and positions exactly as before.

[[element]]
id = "menu"
type = "panel"
parent = "root"
anchor_min = [0.5, 0.5] # a centered panel that holds the buttons
anchor_max = [0.5, 0.5]
offset_min = [-120.0, -100.0]
offset_max = [120.0, 100.0]
layout = "vertical" # stack its direct children top-to-bottom
spacing = 12.0 # 12 px between them
padding = [16.0, 16.0, 16.0, 16.0] # L, R, T, B inset (L/T seed the flow)
[[element]]
id = "playBtn"
type = "button"
parent = "menu"
anchor_min = [0.0, 0.0] # anchors/offsets still give the SIZE (200×44);
anchor_max = [0.0, 0.0] # the vertical flow sets the POSITION
offset_min = [0.0, 0.0]
offset_max = [200.0, 44.0]

A panel or button can draw an image instead of (or tinted under) a flat color. There are two ways to name the image, and a 9-slice mode that scales it without distorting its corners.

  • By path — image = "ui/panel.png" (recommended). A project-relative path to a PNG in the mounted project. The engine resolves it to a texture on the first Ui.render (loaded through the PhysicsFS sandbox, so a path can never escape the project) and caches the handle on the element. This is the authorable form: paths are stable and live in the .ui file.
  • By handle — texture = <int>. A raw renderer texture handle the game obtained at runtime. Handles aren’t stable across runs and can’t be authored by hand; prefer image unless you already hold a handle.

image and texture coexist for backward compatibility — every existing .ui that used a bare texture loads and renders identically. When both are present the resolved image handle is used. PNG only for now (PSD/XCF are deferred).

Set nine_slice = true and border = [L, R, T, B] (a margin in texture pixels on each side) to scale the image as a 9-slice: the four corners keep their pixel size, the four edges stretch along one axis only (top/bottom horizontally, left/right vertically), and the center stretches both ways. This keeps rounded corners and borders crisp while the panel resizes to any rect — the same image backs a small tooltip and a full-screen frame without the border thickening or the corners smearing. With nine_slice = false (the default) the image is drawn as a single stretched quad.

[[element]]
id = "frame"
type = "panel"
parent = "root"
anchor_min = [0.0, 0.0]
anchor_max = [1.0, 1.0]
offset_min = [24.0, 24.0]
offset_max = [-24.0, -24.0]
image = "ui/panel.png" # a mounted PNG
nine_slice = true
border = [16, 16, 16, 16] # L, R, T, B, in texture pixels

Slicing metadata is a property of the image, not of each .ui that uses it — so it can be authored once, next to the asset, in a small TOML sidecar named <image path>.ui.component (e.g. a PNG at ui/panel.png gets ui/panel.png.ui.component). It carries the same fields:

ui/panel.png.ui.component
nine_slice = true
border = [16, 16, 16, 16] # L, R, T, B in texture pixels

When a .ui element references an image by path, the engine loads any sidecar sitting beside it and applies its slicing for values the .ui left at its defaults — so one image = "ui/panel.png" line picks up 9-slice automatically, and many .ui files reuse the same asset without repeating the borders. An explicit border in the .ui element wins over the sidecar (the sidecar can add slicing but not override an element that set its own). The sidecar is optional, PNG-only, and untrusted-input-hardened at the parser boundary (a missing or malformed file is ignored, borders are clamped ≥ 0). See the UI 9-Slice Demo.

A type = "canvas" element is a blank region. After Ui.render, draw into it with these static methods, using canvas-local design-pixel coordinates (0, 0 is the canvas’s top-left) and [r, g, b, a] (0..1) colours. The engine maps canvas-local coordinates to screen through the same letterbox as the rest of the document, so custom drawing scales with it.

  • Ui.canvasRect(canvas, x, y, w, h, color) — a filled rectangle.
  • Ui.canvasLine(canvas, x0, y0, x1, y1, thickness, color) — a thick line.
  • Ui.canvasBezier(canvas, x0, y0, cx0, cy0, cx1, cy1, x1, y1, thickness, color) — a cubic bezier.
  • Ui.canvasText(canvas, x, y, text, size, color) — text at (x, y).

Primitives composite in the retained-UI pass (over panels, under the immediate-mode Gui). See UI Canvas for a node-graph example.

Tools (like apps/ui_editor) build and modify documents at runtime with an authoring tier. Every structural edit keeps the tree valid (a parent always precedes its children) and invalidates existing element handles, so re-fetch by id after an edit.

  • Ui.newDocument(w, h) → an empty UiDocument with the given design size.
  • Ui.createElement(doc, type, parentId) → a new UiElement of "panel"/"text"/"button" under parentId ("" = root).
  • Ui.deleteElement(element) — remove an element and its whole subtree.
  • Ui.reparent(element, newParentId)Bool — move an element ("" = root); false if it would form a cycle.
  • Ui.save(doc, path)Bool — serialize and write the document to a mounted-project path.
  • Ui.hitTest(doc, x, y)UiElement or null — the topmost element at a design-space point (call after render/renderEdit has laid the document out; convert a screen click to design space with the content rect those return).
  • Ui.renderEdit(doc, x, y, w, h) — like render, but in edit mode: a click selects rather than fires, for a visual editor.

On UiDocument: elementAt(i) (the i-th element, for enumerating a tree). On UiElement: type, parentId, id = (String), fontSize / fontSize=, onClick, rect / rect = (List) (the RectTransform as a flat 10-list — anchorMin.xy, anchorMax.xy, offsetMin.xy, offsetMax.xy, pivot.xy), and color / color = (List) ([r, g, b, a], the element’s main colour).

  • UI Editor (apps/ui_editor) — the WYSIWYG visual editor built on this tier.

  • UI Demo (apps/ui_demo) — a .ui card with a title, subtitle, and an interactive button, rendered full-window.

  • UI 9-Slice Demo (apps/ui_nineslice_demo) — two different-sized frames share one image-by-path PNG + a .ui.component sidecar, so their corners stay unstretched.

  • UI Input Demo (apps/ui_input_demo) — editable type = "input" fields: a Name field with initial text and a City field showing a placeholder, focused by click.

  • Gui — the immediate-mode (Nuklear) GUI, for tools and debug overlays.

  • Text — the SDF text pass the UI’s labels reuse.