# node
Core widget-table constructor used by every zui widget. Returned
tables are interoperable with hand-written widget trees, so user-built
widgets can reuse the exact same primitive without depending on the
rest of zui.
## Exports
- `node(widgetType: string, opts: Opts?, children: any?) -> any` — build the widget table. Returned directly by the module.
Types:
- `Opts = { id?: string, classes?: any, class?: any, props?: any, style?: any }`
## Usage
```luau
local node = require("@builtin::modules.zui.widget.node")
-- User-built widget composed on top of node:
return function(text, opts)
return node("label", { props = { text = text } })
end
```
## Notes
- Accepts either a single child table or an array of children; single
children are wrapped automatically (matches `scroll.module`'s fix
from #2331).
- `classes` may be a string (space-separated) or an array — both
normalise to an array of class names.
- `class` is accepted as an alias for `classes` for ergonomic call
sites.
- Returning a function (not a table) keeps consumers terse and avoids
callers needing to know whether the constructor lives on `M.x` or
the module itself.
# widget
Widget namespace — re-exports every widget under `zui.widget.<name>`.
Library users can either pull the whole namespace (`local W =
require("modules.zui.widget")`, then `W.button(...)`) or grab a single
widget directly (`local btn = require("modules.zui.widget.button")`).
Each widget lives in its own `.module/` folder so authors adding a new
widget edit exactly one file. The shared primitive every widget builds
on is `zui.widget.node` — pulled into a user-defined widget the same
way it's pulled in here.
## Exports
This module is a namespace — every field is an assignment from the
matching sibling module. No typed functions live here directly; the
typed surface lives on each sibling.
- `M.node` — core widget-table constructor (`modules.zui.widget.node`).
- Content widgets: `label`, `button`, `icon`, `iconBtn`, `iconButton`,
`chip`, `badge`, `card`, `colorSwatch`, `dialogueBox`, `kbd`, `input`,
`codeEditor`, `slider`, `dragValue`, `checkbox`, `toggle`, `dropdown`,
`datePicker`, `radioGroup`, `selectableList`, `image`, `viewport`,
`progressBar`, `richText`.
- DAW widgets: `fader`, `knob`, `meter`.
- Game-HUD widgets: `healthBar`, `hotbar`, `minimap`.
- Layout containers: `hbox`, `vbox`, `grid`, `split`, `sides`, `panel`,
`section`, `scroll`, `collapsible`, `window`, `modal`, `popup`,
`contextMenu`, `scene`, `area`, `anchor`, `topPanel`, `bottomPanel`,
`leftPanel`, `rightPanel`, `centralPanel`.
- Leafs: `spacer`, `flex`, `sep`.
- Plot / Graph: `plot`, `graph`.
- Drag-and-drop list: `dndList`.
- Canvas: `canvas`.
- Composite widgets: `tabs`, `tree`, `filterRow`, `statRow`, `statusLabel`.
- Sub-namespaces: `lsp`, `fs`.
## Usage
```luau
local W = require("@builtin::modules.zui.widget")
return W.vbox{
W.label("Name:"),
W.input(""),
W.hbox{ W.button("Save", { onClick = "save" }), W.button("Cancel") },
}
-- Or pull single widgets directly:
local btn = require("@builtin::modules.zui.widget.button")
return btn("Save", { onClick = "save" })
```
## Notes
- The exposed `M.node` is the canonical primitive for user-defined
widgets — `require("modules.zui.widget.node")` returns the same value.
- Sub-namespaces (`lsp`, `fs`) collect tightly-coupled widget sets that
only make sense together — isolating them keeps the top-level surface
clean.
- Plot and Graph are pure-Luau builders over canvas; no `egui_plot`
dependency.
- DndList composes per-row canvases + a transparent overlay canvas for
drag-source detection and hover insertion indicators.
- All re-exports are static — adding a new widget means adding a new
`M.<name> = require(...)` line here (and creating the sibling module).
# zui
> **Deprecated for authoring content.** `zui` predates the engine's CSS-parity
> `ui.*` surface and writes unlike CSS, producing flatter results. Author screens
> as raw widget trees (`{ type, style, props, children }`) styled with `ui.*`, and
> read `@builtin::examples.ui.*` for complete worked screens. `zui` remains in use
> internally by the editor; it will be rebuilt on the CSS-parity core.
Convenience UI library on top of the engine's `ui.*` Rust bindings.
Ships Layer 1 (per-widget builders), Layer 2 (theme tokens), Layer 3
(app lifecycle), router with unhandled-warning, pre-built shells, and
debug overlay. The engine's Rust UI surface is the substrate — `zui` is
one library on top of it. Any third-party Lua UI library can build on
the same primitives; the engine has no concept of "the zui library",
only widget types and a stable contract. See
[`docs/specs/ui-v3-architecture.md`](../../../../docs/specs/ui-v3-architecture.md).
## Exports
Layer 1 (widget builders) — short aliases on `Z.*`:
- `Z.lbl`, `Z.btn`, `Z.icon`, `Z.iconBtn`, `Z.iconButton`, `Z.chip`,
`Z.badge`, `Z.card`, `Z.colorSwatch`, `Z.dialogueBox`, `Z.kbd`,
`Z.input`, `Z.codeEditor`, `Z.slider`, `Z.dragValue`, `Z.checkbox`,
`Z.toggle`, `Z.dropdown`, `Z.datePicker`, `Z.radioGroup`,
`Z.selectableList`, `Z.image`, `Z.viewport`, `Z.progressBar`,
`Z.richText`, `Z.fader`, `Z.knob`, `Z.meter`, `Z.healthBar`,
`Z.hotbar`, `Z.minimap`, `Z.hbox`, `Z.vbox`, `Z.grid`, `Z.split`,
`Z.sides`, `Z.panel`, `Z.section`, `Z.scroll`, `Z.collapsible`,
`Z.window`, `Z.modal`, `Z.popup`, `Z.contextMenu`, `Z.scene`,
`Z.area`, `Z.anchor`, `Z.topPanel`, `Z.bottomPanel`, `Z.leftPanel`,
`Z.rightPanel`, `Z.centralPanel`, `Z.spacer`, `Z.flex`, `Z.sep`,
`Z.plot`, `Z.graph`, `Z.dndList`, `Z.canvas`, `Z.tabs`, `Z.tree`,
`Z.filterRow`, `Z.statRow`, `Z.statusLabel`, `Z.node`
- `Z.lsp` — LSP composite-widget namespace.
- `Z.fs` — Filesystem composite-widget namespace.
- `Z.widget` — full widget namespace (for explicit access).
Layer 2 (theme tokens):
- `Z.theme` — active default token table.
- `Z.themeWith(overrides)` — derive a theme with token overrides.
- `Z.themeNames()` — list known token names.
- `Z.Theme` — the underlying module.
Shared utils (re-exported):
- `Z.round`, `Z.fmt`, `Z.fmtVec3`, `Z.id`, `Z.eventValue`, `Z.map`,
`Z.when`, `Z.compact`, `Z.Utils`.
Layers 3-7 (app lifecycle + advanced features):
- `Z.app(name, builder)` — App lifecycle wrapper (`Z.App.create`).
- `Z.dynamic(list, fn)` — one-screen-per-list-item bucket.
- `Z.dataSource(fetchFn, opts)` — TTL + gated cached fetcher.
- `Z.highlight` — syntax-highlighting dispatch and per-language modules.
- `Z.shell` — pre-built shell shapes (docked, canvas, inspector).
- `Z.tags`, `Z.screens` — Luau-owned screen tag registry + lifecycle helpers.
- `Z.defineWidget(name, fn)` / `Z.unregisterWidget(name)` — register Luau builders for raw `{ type = name, ... }` widget tables.
- `Z.widgetState(id, key, default)` / `Z.widgetState.set(id, key, value)` — per-widget reactive state.
- `Z.VERSION`, `Z.SPEC` — version string and spec link.
## Usage
```luau
local Z = require("@builtin::modules.zui")
local C = Z.theme
local function tree()
return Z.centralPanel({
Z.topPanel({
Z.hbox({
Z.lbl("◈ MY APP", { color = C.accent, fontSize = 14, bold = true }),
Z.flex(),
Z.btn("Save", "save:click", { bg = C.accent, color = C.bg, bold = true }),
}, { style = { gap = 8, padding = { 6, 12, 6, 12 } } }),
}),
Z.section("CONTROLS", {
Z.slider("vol", 0.5, 0, 1, { onChange = "vol:set" }),
Z.btn("Reset", "vol:reset"),
}, { bg = C.panel, border = C.border, padding = { 8, 12, 8, 12 } }),
})
end
ui.registerScreen("my-app", tree(), 0)
ui.showScreen("my-app")
```
## Notes
- This module's surface is mostly re-exports: every `Z.*` widget alias
is `Widget.*` (see `widget.module/`), every theme alias is
`Theme.*`, every util is `Utils.*`. The aliases exist for ergonomics
only — explicit access via `Z.widget`, `Z.Theme`, `Z.Utils`, etc.
works the same way.
- The module registers a handful of `ui.defineWidget` handlers at
load time (tabs, card, sides, badge, colorSwatch, separator,
progressBar, iconButton, dialogueBox, hotbar) so raw
`{ type = "<name>", ... }` widget tables in existing demos and editor
tools keep rendering after their Rust-side definitions were deleted.
Re-running registration on hot-reload is safe — the engine replaces
the previous closure.
- `Z.defineWidget` requires the engine `ui` global; off-host execution
raises a clear error.
- `Z.widgetState` is a callable read path with a `.set` write
sub-method — no metatables on the caller side. Returns the `default`
when the engine global is missing.
- The library is built on a stable Rust substrate (`ui.registerScreen`,
`ui.getToken`, `ui.defineWidget`, etc.). Other Lua UI libraries can
build on the same substrate and coexist with `zui`; the engine has
no concept of a privileged library.
- See `docs/specs/ui-v3-architecture.md` for the full layered design,
Rust binding gaps, and follow-up roadmap.
## Tall content inside an anchored panel
A `Z.anchor("Center", ...) { Z.panel(rows) }` whose `rows` exceed the
viewport will silently clip top + bottom — the player has no scrollbar
and no way to reach the off-screen entries. Opt into in-place scrolling
on the panel:
```luau
Z.anchor("Center", {}, {
Z.panel(rows, { scroll = true, maxHeight = 480 }),
})
```
`scroll = true` wraps the children in a `scrollArea` so overflow stays
reachable. Without `scroll = true`, `maxHeight` only clips. See the
[panel README](widget.module/panel.module/README.md) for `scrollMaxHeight`,
header/footer pinning, and the full pattern (closes #3270).
# knob
Rotary knob built on `canvas` interaction props. Renders a 270° dial
as a track polyline + fill polyline + needle line, and wires
drag/reset handlers via `widgetState`. Auto-registers per-id handlers
on the current `Z.app` so callers only need to supply `onChange`.
## Exports
- `knob(id: string, value: number, lo: number?, hi: number?, opts: KnobOpts?) -> WidgetNode` — module returns the builder function directly.
Options:
- `defaultValue: number?` — restored on double-click. Omit to disable reset.
- `onChange: string?` — callback id; fires with `{ value = number }` on drag/reset commit.
- `tooltip: string?` — when set, wraps the canvas in a transparent tooltip panel.
- `width: number?`, `height: number?` — canvas size. Default `34 × 34`.
- `arcWidth: number?` — track/fill stroke width. Default `4`.
- `trackColor: string?`, `fillColor: string?`, `capColor: string?` — palette overrides.
- `style: table?`, `app: App?` — standard plumbing.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.knob("master-gain", state.gain, 0, 1, {
defaultValue = 0.75,
onChange = "audio:gain",
tooltip = "Master gain",
})
```
## Notes
- Vertical drag updates the value: `1px = 0.5% of span`, or `0.05%` when
Shift is held (10× finer for fine adjustment). Drag *up* increases.
- Double-click restores `defaultValue` if set; otherwise no-op.
- Live state lives in `ui.widgetState(id, "value")` — first render seeds
from the supplied `value`, thereafter the drag handler owns it. This
mirrors the `Z.collapsible` auto-stash pattern.
- Handlers are registered once per id via `app._knobHandlers`; subsequent
renders are no-ops on the handler side.
- The fill polyline is omitted when only one segment is filled so the
renderer's min-2-points guard isn't tripped.
- The tooltip wrap is only added when `opts.tooltip` is set so non-tooltip
knobs stay flat.
# minimap
Luau builder over `canvas` for a square minimap. Renders a 150×150
dark-green square with a single light-green centre marker by default,
and accepts an optional `markers` array so callers can paint arbitrary
points (entities, waypoints, POIs) without needing a new Rust widget
kind. Marker coordinates are normalised — `[0..1]` × `[0..1]` —
clamped so out-of-bounds entries paint at the edge.
## Exports
- `minimap(opts: Opts?) -> any` — build the canvas node. Returned directly by the module.
Types:
- `Marker = { x?: number, y?: number, color?: string, radius?: number }`
- `Style = { width?: number, height?: number, background?: string }`
- `Opts = { id?, classes?, label?, size?, background?, borderColor?, borderWidth?, borderRadius?, markers?: { Marker }, style?: Style }`
## Usage
```luau
local minimap = require("@builtin::modules.zui.widget.minimap")
local widget = minimap({
size = 200,
markers = {
{ x = 0.5, y = 0.5, color = "#FFC832", radius = 5 }, -- player
{ x = 0.2, y = 0.8, color = "#64C8FF" }, -- ally
{ x = 0.7, y = 0.3, color = "#C84040" }, -- enemy
},
})
```
## Notes
- DOM mirror exposes the widget as `<canvas role="img"
aria-label="Minimap">` via the generic `role` / `aria*` prop
pass-through.
- Legacy single-dot fallback ensures existing demos keep rendering
even when no markers are supplied.
- The marker `radius` and `color` are per-marker — use them to encode
per-entity context (size for distance, colour for faction, etc.).
# datePicker
Calendar-popup date picker. Wraps `egui_extras::DatePickerButton`
(egui_extras 0.34 with the `datepicker` feature, enabled in
`crates/zero_ui/Cargo.toml`). Value flows as an ISO-style date string;
format defaults to `"%Y-%m-%d"` and can be overridden with a jiff
strftime spec.
## Exports
- `datePicker(id: string?, value: any?, opts: DatePickerOpts?) -> WidgetNode` — build a date-picker widget node.
Types:
- `DatePickerOpts = { id?, props?, style?, format?, onChange?, enabled?, focusable?, tooltip? }`
## Usage
```luau
local datePicker = require("@builtin::modules.zui.widget.datePicker")
datePicker("startDate", "2026-05-03", {
onChange = "startDateChanged",
})
datePicker("birthday", "1990/01/15", {
format = "%Y/%m/%d",
onChange = "birthdayChanged",
focusable = true, -- Wave 5 (#2416) prep; informational today
})
```
## Notes
- `onChange` receives the newly-formatted date string as `value`.
- The widget id falls back to `opts.id` when the `id` positional is nil.
- Date format follows the jiff strftime specifier syntax (e.g. `"%Y-%m-%d"`, `"%Y/%m/%d"`).
# statRow
Label-value row — `[label] [value]` — with consistent label width,
theme-token defaults, and an optional value class/color. Used wherever
a stats panel hand-rolls a tiny `Z.hbox(label, value)` (runtime /
profiler / physics tabs).
`value` may be a string/number (rendered as a label) or a widget table
(rendered as-is — e.g. `Z.statusLabel`).
## Exports
- `statRow(labelText: any, value: any, opts: StatRowOpts?) -> any` — build a label-value row widget. The module returns this function directly.
Types:
- `StatRowOpts = { id: string?, class: (string | { string })?, fontSize: number?, labelWidth: number?, labelMinWidth: number?, labelColor: string?, labelClass: (string | { string })?, valueColor: string?, valueClass: (string | { string })?, bold: boolean?, gap: number?, align: string?, padding: any? }`
## Usage
```luau
local statRow = require("@builtin::modules.zui.widget.statRow")
statRow("Heap allocated", "12.4 MB", {
labelWidth = 140, valueClass = "debug-good", fontSize = 11,
})
statRow("Frame", Z.statusLabel(dtMs, { good = 16, warn = 33 }))
```
## Notes
- Label width defaults to 140 px, font size to 11.
- Label / value colors fall back to the active theme's `text_dim` /
`text` tokens respectively.
- The value slot accepts a widget table directly, which is the
idiomatic way to inline status-coloured values.
# popup
Anchored floating popup. Wraps `egui::Popup` (added 0.32). Pins
itself to a referenced widget's rect — useful for button-anchored
dropdowns, autocompletes, callouts. The anchor MUST render before the
popup in the tree, otherwise the rect lookup misses and the popup
silently does not paint.
## Exports
- `popup(opts: Opts?) -> any` — build the popup widget node. Returned directly by the module.
Types:
- `Opts = { id?, anchor: string, pivot?, open?, onDismiss?, focusable?, children?, props?, style? }`
## Usage
```luau
local popup = require("@builtin::modules.zui.widget.popup")
Z.btn("Save", "saveBtn"),
popup({
anchor = "saveBtn",
open = state.popupOpen,
onDismiss = "save:popup-dismiss",
children = {
Z.btn("PNG", "save:png"),
Z.btn("JPG", "save:jpg"),
},
})
```
## Notes
- The `anchor` is required — the wrapper asserts and errors loudly on
nil rather than silently failing.
- Pivot maps to egui's `RectAlign`: `"belowLeft"` → BOTTOM_START
(default), `"belowRight"` → BOTTOM_END, `"aboveLeft"` → TOP_START,
`"aboveRight"` → TOP_END.
- Auto-dismiss fires `onDismiss` on click-outside / Escape so the
caller can mirror the new state. Falls back to `<id>-dismiss` if
`onDismiss` is omitted.
- The Lua-facing key is `anchor`; the underlying Rust prop is
`anchorTo` (egui already claims `anchor` for an enum). The wrapper
bridges the two names automatically.
# slider
Horizontal slider for a numeric value in `[min, max]`. Emits the given
`onChange` callback id with `data.value` set to the new value. Pass
`step` to constrain to discrete increments.
## Exports
- `slider(id: string, value: number?, lo: number?, hi: number?, opts: SliderOpts?) -> any` — build a horizontal slider widget. The module returns this function directly.
Types:
- `SliderOpts = { props: { [string]: any }?, style: { [string]: any }?, onChange: string?, step: number? }`
## Usage
```luau
local slider = require("@builtin::modules.zui.widget.slider")
slider("volume", 0.5, 0, 1, { onChange = "ui:volume:change", step = 0.05 })
```
## Notes
- `value`, `lo`, `hi` all default to `0`, `0`, `1` respectively when nil.
- `onChange` callbacks fire with `data.value` set to the new numeric
value; route them via `component.onCallback` or a `Z.app` router.
# grid
Fixed-column grid container. Children flow left-to-right then wrap.
`columns` is required.
## Exports
- `grid(children: { WidgetNode }?, columns: number, opts: GridOpts?) -> WidgetNode` — build a fixed-column grid container.
Types:
- `GridOpts = { id: string?, classes: (string | { string })?, props: { [string]: any }?, style: { [string]: any }? }`
- `WidgetNode = { [string]: any }` — widget table (interoperable with hand-written widget trees).
## Usage
```luau
local Z = require("@builtin::modules.zui")
local g = Z.grid({ a, b, c, d }, 2, { style = { gap = 4 } })
```
## Notes
- Pure builder — no state, no engine calls. Safe at module load.
- `children` defaults to `{}` when nil; `columns` has no default and
must be provided.
- The `columns` value is forwarded to the engine as `props.columns`;
any other props the caller passes survive untouched.
# selectableList
Luau builder for a single-select listbox composed of focusable per-row
canvases inside a panel. Same shape as `Z.radioGroup`, but rows draw
no glyph — they're highlight-on-selection labels — and the wrapping
panel declares `role="listbox"` / row `role="option"` so screen
readers read it as a single-select list.
## Exports
- `selectableList(id: string, options: { any }?, selected: number?, opts: SelectableListOpts?) -> any` — build a listbox widget. The module returns this function directly.
Types:
- `SelectableListOpts = { style: { [string]: any }?, classes: (string | { string })?, rowWidth: number?, rowHeight: number?, onChange: string?, app: any?, ariaLabel: string?, label: string? }`
## Usage
```luau
local selectableList = require("@builtin::modules.zui.widget.selectableList")
selectableList("category", CATEGORIES, state.cat, {
onChange = "ui:cat:change",
})
```
## Notes
- Single-select; `selected` is a 1-based index. The first call seeds
`ui.widgetState(id, "selected")` from the caller's arg; subsequent
ticks read state back from there.
- Keyboard nav (per-row canvas `onKey`): `ArrowDown` / `ArrowRight`
next, `ArrowUp` / `ArrowLeft` previous, `Home` -> 1, `End` ->
`#options`. Tab walks the per-row canvases via egui's focus chain.
- For sidebar / asset-browser / category-picker patterns where every
option is visible at once. For long lists where only a few items fit,
wrap the result in `Z.scroll(...)`.
- `opts.onChange` is dispatched as a callback id on the surrounding
`Z.app` router with `data.value` set to the new 1-based index.
Without an app in scope, only `ui.widgetState` updates.
- Handlers are registered once per group id (deduped via
`app._selectableListHandlers`); option-count and `onChange` swaps
land without re-registering.
# chip
Compact tag/badge. Variants (`"info"`, `"success"`, `"warning"`,
`"error"`) pick a foreground/background colour pair from the active
theme. Built as a `panel + label` composition so the engine has no
dedicated arm for what is fundamentally a coloured rounded text-box.
## Exports
- `chip(text: string?, opts: ChipOpts?) -> WidgetNode` — build a chip widget node.
Types:
- `ChipVariant = "info" | "success" | "warning" | "warn" | "error" | "danger"`
- `ChipOpts = { id?, variant?, bg?, color?, fontSize?, padding?, borderRadius? }`
## Usage
```luau
local chip = require("@builtin::modules.zui.widget.chip")
chip("New", { variant = "success" })
chip("Beta", { bg = "#222", color = "#fff" })
```
## Notes
- `bg` and `color` per-call override the variant-selected colours.
- Defaults: `fontSize = 11`, `padding = { 1, 6, 1, 6 }`, `borderRadius = 4`.
- Colours pull from `Theme.default` (`success`, `warn`, `danger`, `info`, `bg_deep`).
# lsp
LSP composite widgets — pure builders that turn the engine's `lsp.*`
data shapes into widget trees. Six pieces gathered onto a single `M`
table so callers can `require` the namespace and pick out individual
builders. These are stateless: pass data in, get a widget tree out.
## Exports
- `M.severitySummary(counts, opts?) -> WidgetNode` — hbox of count chips per severity.
- `M.diagnosticsList(diags, onSelect, opts?) -> WidgetNode` — scrollable vbox of diagnostic rows.
- `M.diagnosticDetail(diag, opts?) -> WidgetNode` — detail panel for one diagnostic.
- `M.docBrowser(state, opts?) -> WidgetNode` — namespaces / methods / describe browser.
- `M.strictModeToggle(mode, onChange, opts?) -> WidgetNode` — three-button radio.
- `M.directiveBadge(skipMode, opts?) -> WidgetNode?` — chip indicating the file's `--!skip` mode, or `nil`.
Each export is the builder module's `return function(...)`; see the
sibling `*.module/README.md` files for the per-builder signatures.
## Usage
```luau
local Z = require("@builtin::modules.zui")
local lsp = require("@builtin::modules.zui.widget.lsp")
return Z.vbox({
lsp.severitySummary(counts),
lsp.diagnosticsList(diags, "lsp:row:select"),
lsp.diagnosticDetail(diags[selectedIndex], { source = source }),
})
```
## Notes
- Layer B (`lsp_ui.module`) and Layer C (system_tools' LSP tab) compose
these with their own state + polling — but you don't have to: roll
your own.
- Each entry is loaded lazily by `require`. Hot-reloading any sibling
re-imports it on next call.
- This namespace owns no state. The composite layers above this one do.
# anchor
Corners a child to a screen edge. Root-only — must be the top-level
widget of its registered screen. `anchor` is one of `TopLeft / TopCenter
/ TopRight / CenterLeft / Center / CenterRight / BottomLeft / BottomCenter
/ BottomRight`. `margin` is `{ top, right, bottom, left }`.
## Exports
This module returns the widget factory directly. There is no returned
table.
- `anchor(anchor: AnchorPos, margin: Margin, children: { Node }?, opts: AnchorOpts?) -> Node` — build an anchor widget node.
Types:
- `AnchorPos = "TopLeft" | "TopCenter" | "TopRight" | "CenterLeft" |
"Center" | "CenterRight" | "BottomLeft" | "BottomCenter" |
"BottomRight"`
- `Margin = { number }` — `{ top, right, bottom, left }`.
- `Node = { [string]: any }` — opaque widget node.
- `AnchorOpts = { id: string?, props: { [string]: any }?, style: { [string]: any }? }`
## Usage
```luau
local anchor = require("@builtin::modules.zui.widget.anchor")
local closeBtn = anchor("BottomRight", { 0, 16, 16, 0 }, {
Z.btn("Close", { onClick = "close" }),
})
ui.registerScreen("close", closeBtn)
```
## Notes
- The widget MUST be the top-level node of its registered screen — the
engine's `anchor` decoder only resolves at the screen root.
- `margin` is injected into `props.margin`; `anchor` into `props.anchor`.
`opts.props` is mutated in place — pass a fresh table if you reuse it
across calls.
- Single-child `children` (a single widget node) is normalised by `node`,
so callers don't need to wrap a single child in `{ ... }`.
# dockPanel
A single dockable panel inside a `dockArea`. Its widget `id` is the stable tab id the dockArea uses for reconciliation and for the `<id>-close` interaction fired when the tab is closed. Children form the tab's content subtree, rendered by the dockArea's TabViewer.
The module returns the widget builder function directly.
## Builder
`dockPanel(opts?) -> widget node`. `opts` fields:
- `id` (required) — the stable tab id.
- `title` — the tab label shown in the dock tab bar; defaults to the panel `id`.
- `closable` — whether the tab shows a close button (default true). On close the dockArea emits `<id>-close`.
- `float` — when a NEW panel first appears with `float = true`, the dockArea opens it as a floating, movable, resizable dock-window over the content behind instead of in the focused leaf.
- `children`, `props`, `style`.
## Usage
```luau
local dockPanel = require("modules.zui.widget.dockPanel")
local widget = dockPanel({ id = "props", title = "Properties", children = {
Z.lbl("Inspector body"),
}})
```
# bottomPanel
Bottom-docked panel. Root-only — must be the top-level widget of its
registered screen. Pairs with `centralPanel` (and optionally `topPanel` /
`leftPanel` / `rightPanel`) for a docked app shell — register each as
its own screen with appropriate layer ordering.
## Exports
This module returns the widget factory directly. There is no returned
table.
- `bottomPanel(children: { Node }?, opts: PanelOpts?) -> Node` — build a bottom-docked panel node.
Types:
- `Node = { [string]: any }` — opaque widget node.
- `PanelOpts = { id: string?, classes: ({ string } | string)?,
class: ({ string } | string)?, props: { [string]: any }?,
style: { [string]: any }? }`
## Usage
```luau
local bottomPanel = require("@builtin::modules.zui.widget.bottomPanel")
local statusBar = bottomPanel({
Z.hbox{ Z.lbl("Status: OK"), Z.flex(), Z.lbl("v0.1.0") },
}, { id = "status" })
ui.registerScreen("statusBar", statusBar)
```
## Notes
- Root-only — the widget MUST be the top-level node of its registered
screen.
- Children can be a single Node or an array; `node` normalises a single
child to `{ child }` so callers don't have to wrap manually.
- `classes` / `class` accept either an array of strings or a single
space-separated string — `node` normalises both forms.
- Pairs with the other panel widgets for a docked app shell; register
each on its own screen so layer ordering / visibility can be controlled
independently.
# iconButton
Square button rendered with the phosphor glyph font. Thin alias for
`Z.iconBtn` — re-requires the same builder so the call shapes are
identical. Pick whichever name reads better at the call site.
## Exports
- `iconButton(iconText: string, callbackId: string, opts: table?) -> WidgetNode` — module returns the `iconBtn` builder directly.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.iconButton("", "save:click", { id = "save", tooltip = "Save" })
```
## Notes
- This module is a one-line `return require("modules.zui.widget.iconBtn")`.
- See `iconBtn` for full option semantics.
- Useful when reading code: `iconButton` reads more naturally in some
call sites, `iconBtn` is shorter for inline use.
# badge
Alias for chip — kept as its own module for vocabulary clarity. Pick
whichever name reads better at the call site; both compile to the same
widget under the hood.
## Exports
This module re-exports `modules.zui.widget.chip` directly. There are no
typed functions or types declared here — the public surface is whatever
`chip` exposes.
- `badge(...) -> Node` — identical to `chip(...)`.
## Usage
```luau
local badge = require("@builtin::modules.zui.widget.badge")
local newBadge = badge("New", { variant = "info" })
```
## Notes
- This module is a thin re-export — `badge == chip` (same function
reference). Hot-reloading either reloads both.
- See `modules.zui.widget.chip` for the full argument / option surface
and type signature.
- The alias exists purely so call sites can say `badge` when that reads
better (e.g. "new feature" badge) and `chip` when that reads better
(e.g. a removable tag chip). There is no behavioural difference.
# card
Card composite — title + description + optional image placeholder +
arbitrary children inside a styled panel, built from `panel` + `label`
primitives. Pure Luau composition; the engine has no dedicated `card`
arm.
## Exports
- `card(opts: CardOpts?) -> WidgetNode` — build a card widget node.
Types:
- `CardOpts = { id?, classes?, class?, title?, titleColor?, description?, descriptionColor?, image?, children?, bg?, border?, borderWidth?, padding?, gap? }`
## Usage
```luau
local card = require("@builtin::modules.zui.widget.card")
card({
title = "Card title",
description = "muted secondary text",
image = "asset/path", -- optional 100px tall placeholder bar
children = { ... }, -- arbitrary widgets below
id = "myCard",
padding = 8,
})
```
## Notes
- Click handling is not exposed directly — wrap in a clickable wrapper (e.g. an outer `Z.btn` with empty text) if needed.
- The image slot currently renders a `panel_alt`-coloured placeholder rect; once an image-loader pipeline lands, callers can pass a real `Z.image` instead.
- Theme defaults (`panel`, `border`, `text`, `text_dim`) come from `modules.zui.theme`'s `default` table.
# panel
Bordered surface for grouping widgets. Accepts top-level shortcut
keys (`bg`, `border`, `borderWidth`, `padding`, `gap`, `minWidth`,
`minHeight`, `maxWidth`, `maxHeight`) so the common case doesn't
require nesting under `style = { ... }`.
## Exports
- `panel(children: { any }?, opts: Opts?) -> any` — build the panel widget node. Returned directly by the module.
Types:
- `Opts = { id?, classes?, props?, style?, bg?, border?, borderWidth?, padding?, gap?, minWidth?, minHeight?, maxWidth?, maxHeight?, scroll?, scrollMaxHeight?, scrollId? }`
## Usage
```luau
local panel = require("@builtin::modules.zui.widget.panel")
local widget = panel({
Z.lbl("Header"),
Z.btn("Action", "click"),
}, {
bg = "#101010",
border = "#3a3a3a",
borderWidth = 1,
padding = 8,
})
```
## Tall panels — scrolling overflow (closes #3270)
`Z.anchor("Center", ...) { Z.panel(rows) }` with more rows than the
viewport will silently clip top + bottom of the list — content scrolls
off-screen with no affordance. Opt into in-place scrolling by passing
`scroll = true` alongside a `maxHeight`:
```luau
local rows = {}
for i = 1, 100 do rows[#rows + 1] = Z.lbl("row " .. i) end
-- Header + footer pin to the panel; the rows scroll between them.
return Z.anchor("Center", {}, {
Z.panel({
Z.lbl("Codex", { bold = true }),
Z.sep(),
Z.panel(rows, { scroll = true, maxHeight = 480 }),
Z.sep(),
Z.btn("Close", "codex:close"),
}, { bg = "#101010", padding = 12 }),
})
```
Without `scroll = true`, `maxHeight` only clips — `scroll = true` wraps
the children in a `scrollArea` so overflow rows are reachable via the
scrollbar. `scrollMaxHeight` (optional) bounds only the scroll region
when you want the panel itself to size to the header + footer and let
the scroll region claim what's left.
## Notes
- Top-level shortcuts merge into `opts.style` (caller-supplied keys
take precedence — explicit `style.background` wins over `opts.bg`
only because the shortcut writes to `style.background` after `style`
is captured).
- Defers to `node("panel", ...)` so any panel-specific renderer arm
changes flow through automatically.
- Children default to `{}` — an empty panel renders as a bordered
spacer.
- `scroll = true` inserts a single `scrollArea` child wrapping the
caller's children. Pair with a `maxHeight` (or `scrollMaxHeight` to
bound only the scroll region) so the scrollArea has something to
scroll against — without a bound the scrollArea fills its parent and
no scrolling is observed.
# collapsible
Collapsible section with a header bar. Click the header to expand or
collapse the children. Controlled by Luau — the wrapper auto-stashes
open/closed state via `ui.widgetState(id, "open")` and registers a
one-time click handler with the surrounding `Z.app` so existing demos
that pass `defaultOpen = true` keep working unchanged.
## Exports
- `collapsible(header: any?, children: { any }?, opts: CollapsibleOpts?) -> WidgetNode` — build a collapsible widget node with the supplied header and children.
Types:
- `CollapsibleOpts = { id?, props?, style?, defaultOpen?, app? }`
## Usage
```luau
local collapsible = require("@builtin::modules.zui.widget.collapsible")
collapsible(label("Advanced"), {
-- collapsible children here
}, { id = "advanced-section", defaultOpen = false })
```
## Notes
- Inside `Z.app`: state is auto-managed via `ui.widgetState` + a one-time `app:on` handler keyed off `id`.
- Outside `Z.app`: falls back to a static render keyed off `defaultOpen`; the caller must drive `props.open` and react to `ValueChanged(new_open)` themselves.
- State APIs are flat top-level FFI functions — `ui.widgetState(id, key)` and `ui.widgetStateSet(id, key, value)`, **not** `ui.widgetState.set`.
- `id` is required for cross-frame state to persist. Without an id, the widget renders but the toggle won't reflect — the wrapper logs no warning, just degrades gracefully.
# dndList
Drag-and-drop reorderable list. Each child renders inside a vbox of
two stacked nodes — the original child plus an overlay canvas sized to
the row that captures drag and pointer-move events. Built on `onDrag`
+ `onPointerMove` canvas interactions; the engine has no dedicated
dnd-list arm.
## Exports
- `dndList(id: string?, children: { any }?, opts: DndListOpts?) -> WidgetNode` — build a drag-reorderable list widget node.
Types:
- `DndListOpts = { rowHeight?, rowWidth?, style?, app?, onReorder?, classes?, barColor?, dragStroke?, label? }`
## Usage
```luau
local dndList = require("@builtin::modules.zui.widget.dndList")
dndList("playlist", {
Z.lbl("Track 1"),
Z.lbl("Track 2"),
Z.lbl("Track 3"),
}, { onReorder = "playlist:reorder" })
-- Then in `onCallback`:
-- data.value = { from = 1, to = 3, value = "1,3" }
-- Caller re-renders with the children rearranged.
```
## Notes
- Reorder events fire only on drag stop (`dragStopped = true`) and only when `from ≠ to`. The payload includes both numeric `from` / `to` and a legacy `"from,to"` string that the engine test suite asserts on.
- Defaults: `rowHeight = 28`, `rowWidth = 240`, `barColor = "#78b4ff"`, `dragStroke = "#78b4ff78"`.
- Auto-registers `<id>:drag:<index>` and `<id>:hover:<index>` handlers on the surrounding `Z.app`, deduped by id. Drag state lives in `app._dndListState[id]` and persists across renders.
- Outside `Z.app` (no app in scope), the widget renders but the overlay never draws the insertion bar — drag interaction is inert.
- The outer Panel exposes `role = "list"` and `ariaLabel = opts.label or "Reorderable list"` for accessibility.
# viewport
Embedded 3D viewport rendered into the UI. `target` is a render-target
handle id (paired with the Camera component's `renderTarget` setting).
Use for in-UI minimaps, picture-in-picture, scene previewers.
## Exports
- `viewport(target, opts?) -> Node` — returns the viewport widget node. Module returns the builder function directly.
Types:
- `ViewportOpts = { id?, props?, style? }`
## Usage
```luau
local viewport = require("@builtin::modules.zui.widget.viewport")
viewport(minimapTarget, { style = { width = 200, height = 200 } })
```
## Notes
- Pair with a Camera component whose `renderTarget` matches `target` —
the widget itself doesn't choose what to render, it just displays
the target's contents.
- Sizing comes from `style.width` / `style.height` (or layout); the
target's pixel resolution is set when the render target is created.
# fader
DAW-style vertical fader built on `canvas` interaction props. Draws
track + fill + cap as `rect` commands plus a centred grip `line`, and
wires drag / click / double-click handlers via `widgetState`. Drag and
click both project the pointer Y onto the track — grabbing or
clicking anywhere snaps the cap to that Y. Double-click resets to
`defaultValue` when set.
## Exports
- `fader(id: string, value: number?, lo: number?, hi: number?, opts: FaderOpts?) -> WidgetNode` — build the fader canvas. Auto-registers `<id>:drag`, `<id>:click`, `<id>:reset` handlers (deduped). When `opts.tooltip` is set, the canvas is wrapped in a transparent panel that hosts the tooltip.
Types:
- `FaderOpts = { defaultValue: number?, onChange: string?, tooltip: string?, style: { [string]: any }?, width: number?, height: number?, trackColor: string?, capColor: string?, fillColor: string?, app: any? }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.fader("master-volume", state.volume, 0, 1, {
defaultValue = 0.8,
onChange = "audio:volume",
})
app:on("audio:volume", function(v)
state.volume = v -- the new numeric value
end)
```
## Notes
- Track top = `hi`, track bottom = `lo` (egui +y is down). Pointer Y
is projected onto the track range to produce the new value, then
clamped to `[min(lo,hi), max(lo,hi)]`.
- Per-id handlers are deduped — re-renders don't accumulate listeners.
Mutating width/height between renders means the *first* render's
geometry wins (matches every other auto-stash widget).
- Double-click is a no-op when `opts.defaultValue` is nil.
# dialogueBox
Narrative dialogue panel — speaker name + dialogue text + per-option
response buttons inside a styled panel. Built as a primitive
composition (`panel + label + btn`), so the engine has no dedicated
arm.
## Exports
- `dialogueBox(opts: DialogueBoxOpts?) -> WidgetNode` — build a dialogue widget node.
Types:
- `DialogueOption = { text?, enabled?, tooltip? }`
- `DialogueBoxOpts = { id?, speaker?, dialogue?, options?, bg?, border?, borderWidth?, padding?, gap? }`
## Usage
```luau
local dialogueBox = require("@builtin::modules.zui.widget.dialogueBox")
dialogueBox({
id = "dlg1",
speaker = "Captain",
dialogue = "We've got incoming. Pick your move.",
options = {
{ text = "Engage" },
{ text = "Retreat", enabled = false },
{ text = "Hail", enabled = true },
},
})
```
## Notes
- Each option emits a click event keyed `<id>-<index>` (1-based), so a single pattern handler captures every response:
```luau
app:on("^dlg1%-(%d+)$", function(_, _, idx)
handleResponse(tonumber(idx))
end)
```
- When `opts.id` is omitted, the callback prefix falls back to `"dialogueBox"`.
- `option.enabled` defaults to `true` (the wrapper checks `option.enabled ~= false`).
- Styling defaults pull from `Theme.default` (`panel`, `border`, `text`, `text_bright`).
# richText
Read-only multi-colored text widget. Pass a flat list of
`{ text, color, italic?, monospace? }` segments and the renderer builds
a single-flow `LayoutJob` from them — no edit buffer, no cursor, no
input handling. The same segment shape is consumed by `TextInput`
when `props.segments` is set; `Z.codeEditor` builds segments via the
highlighter modules under `zui.highlight.*` and passes them to
`TextInput`.
## Exports
- `richText(segments: { RichTextSegment }?, opts: RichTextOpts?) -> any` — build a styled-text widget. The module returns this function directly.
Types:
- `RichTextSegment = { text: string, color: string?, italic: boolean?, monospace: boolean? }`
- `RichTextOpts = { id: string?, classes: (string | { string })?, style: { [string]: any }? }`
## Usage
```luau
local richText = require("@builtin::modules.zui.widget.richText")
richText({
{ text = "hello ", color = "#fff" },
{ text = "world", color = "#0E639C" },
})
```
## Notes
- Read-only: no edit buffer, no cursor, no input handling. Use
`Z.codeEditor` or `TextInput` directly when text needs to be editable.
- The renderer produces a single-flow `LayoutJob` from the segments,
so a mid-sentence color change does not break line wrapping.
# kbd
Keybind widget — a Luau builder over `hbox` + `button` (or a
focusable `canvas` while capturing). Uses canvas `onKey` and
`ui.focus(id)` to capture the next pressed key from primitives. Has
two call shapes that produce the same widget kind: a display-only
form for showing a key combo, and an interactive capture form for
rebinding.
## Exports
- `kbd(keysOrOpts: string | KbdOpts, opts: table?) -> WidgetNode` — module returns the builder function directly. The first arg is polymorphic.
Display-only call: `kbd("Ctrl+S")` or `kbd("Ctrl+S", { style = {...} })`.
Interactive call (table form):
- `id: string` — required; identifies the widget for state + handlers.
- `label: string?` — left-side label. Default `"Key"`.
- `key: string?` — current binding text. Default `"None"`.
- `onChange: string?` — callback id; fires on commit with `{ value = "F1" }`.
- `app: App?` — explicit app; falls back to `App.current()`.
- `buttonWidth`, `buttonHeight`, `gap`, `labelStyle`, `buttonStyle`, `canvasStyle`, `style`, `classes` — layout/style overrides.
## Usage
```luau
local Z = require("@builtin::modules.zui")
-- Display only:
Z.kbd("Ctrl+S")
-- Interactive:
Z.kbd({
id = "settings:bind:jump",
label = "Jump",
key = state.jumpKey,
onChange = "ui:bind:jump", -- fires with { value = "F1" }
})
```
## Notes
- Behaviour while capturing: clicking the `[<key>]` button transitions
to capturing and focuses the per-instance hidden canvas. Any key
press commits and leaves capturing. `Escape` cancels (capturing flips
off, key unchanged).
- Captured state lives in `ui.widgetState(id, "capturing"|"key")` so
the wrapper survives screen rerenders without the caller threading
it through.
- `onChange` fires only on commit, not on cancel.
- Per-id handler registration is deduped via `app._keybindHandlers`
(mirrors the `radioGroup` pattern); `onChange` swaps land on each
render without re-registering.
- Calling the table form without an `id` falls back to display-only.
# topPanel
Top-docked panel. Root-only. Pairs with `centralPanel` (and
optionally `bottomPanel`/`leftPanel`/`rightPanel`) for a docked app
shell — register each as its own screen with appropriate layer
ordering.
## Exports
- `topPanel(children, opts?) -> Node` — returns the top-panel widget node. Module returns the builder function directly.
Types:
- `TopPanelOpts = { id?, classes?, props?, style? }`
## Usage
```luau
local topPanel = require("@builtin::modules.zui.widget.topPanel")
topPanel({ menubar, breadcrumb })
```
## Notes
- Must be the top-level widget of the screen it's registered to —
nested topPanels render incorrectly.
- Pair with `centralPanel`/`bottomPanel`/`leftPanel`/`rightPanel` for
full docked-shell layouts; each lives on its own screen so their
layer ordering controls the dock.
# dataView
Filterable, multi-selectable data list bound to a named
`modules.api.editor.selection` scope. `props.mode` ("list" | "table" |
"grid", default "list") selects the rendering strategy:
- **list** — one focusable canvas row per visible item (icon + label +
badge) inside a virtualized `scrollArea`.
- **table** — the same focusable-row shape extended to multiple
columns, with a sortable header.
- **grid** — a wrapping `Z.grid` of selectable thumbnail cells, for
asset browsers.
## Exports
- `dataView(props: Props?) -> any` — build a DataView widget. The
module returns this function directly.
Types:
- `Props = { id: string?, items: { any }?, key: ((any) -> string)?, row: ((any) -> RowSpec)?, selection: Scope?, onActivate: string?, filter: string?, rowHeight: number?, rowWidth: number?, maxHeight: number?, mode: string?, app: any?, columns: { ColumnSpec }?, gridColumns: number?, commands: { string }? }`
- `RowSpec = { label: string, icon: string?, badge: string?, columns: { [string]: string }?, thumb: string? }`
- `ColumnSpec = { id: string, label: string, width: number?, align: ("left" | "right" | "center")?, sort: boolean? }`
- `Scope = { name: string }` — matches `editorSelection.Scope`.
`id`, `key`, `row`, and `selection` are required at runtime (a missing
one raises an `error`); `columns` is additionally required for table
mode. Every `Props` field is typed optional so `props or {}` at the
registration boundary stays well-typed.
## Usage
```luau
local dataView = require("@builtin::modules.zui.widget.dataView")
local Selection = require("@builtin::modules.api.editor.selection")
local scope = Selection.scope("asset")
-- list mode
dataView{
id = "assets", items = assets, selection = scope,
key = function(a) return a.guid end,
row = function(a) return { label = a.name, icon = a.icon } end,
onActivate = "assets:open",
filter = state.filterText,
}
-- table mode
dataView{
id = "assetsTable", mode = "table", items = assets, selection = scope,
key = function(a) return a.guid end,
row = function(a) return { columns = { name = a.name, type = a.assetType, size = tostring(a.size) } } end,
columns = {
{ id = "name", label = "Name", width = 160, sort = true },
{ id = "type", label = "Type", width = 100, sort = true },
{ id = "size", label = "Size", width = 80, align = "right", sort = true },
},
}
-- grid mode
dataView{
id = "assetsGrid", mode = "grid", items = assets, selection = scope,
key = function(a) return a.guid end,
row = function(a) return { label = a.name, thumb = a.path .. "/preview.png" } end,
gridColumns = 4,
}
-- right-click command menu (list/table mode)
local Commands = require("@builtin::modules.api.editor.commands")
Commands.declare({ id = "asset.rename", title = "Rename", category = "Assets", run = function(ctx) ... end })
dataView{
id = "assets", items = assets, selection = scope,
key = function(a) return a.guid end,
row = function(a) return { label = a.name } end,
commands = { "asset.rename", "-", "asset.delete" },
}
```
## Notes
- Selection is read from and written to `props.selection` in every
mode — the widget holds no selected-ids of its own, so every other
view sharing the scope always agrees with what's drawn. Only the
click anchor, keyboard focus position, and (table mode) sort
key/direction live in `ui.widgetState`, because none of them has a
home in the selection model.
- Row click resolves through `selectionModel.resolveClick` (plain /
ctrl / shift semantics) using positions in the CURRENT filtered/
sorted view, not indices into `props.items` — a shift-range always
spans what's visually between the anchor and the click.
- The canvas `onClick` payload carries only the cursor position and
button, never modifier keys, so ctrl/shift state is read from
`input.isDown("ControlLeft" | "ControlRight" | "ShiftLeft" | "ShiftRight")`
at the moment of the click — the same substrate `modules.zinput.rebind`
polls for modifier-aware interactions.
- Keyboard nav (list/table row canvas `onKey`): `ArrowDown` / `ArrowUp`
move focus and single-select the new row, `Home` / `End` jump to the
first/last visible row, `Enter` dispatches `props.onActivate` for the
focused row. Double-clicking a row also dispatches `props.onActivate`.
Grid cells are panels (generic `onClick` only) — they select but
don't carry keyboard nav or double-click.
- Table mode's column header cells dispatch a `<id>:sort:<colId>`
click for any column with `sort = true`, cycling
none/other-column -> ascending -> descending -> none. Sort compares
`row(item).columns[col.id]` (the same text shown in the cell), so a
numeric-looking column sorts lexicographically unless the cell text
is itself a plain unpadded number.
- Every scrollable body (`virtual = true` list/table rows, the grid's
wrapping `Z.grid`) is wrapped with BOTH `maxHeight` and `minHeight`
set to the same value. Without `minHeight`, nesting the scrollArea
under a header (table mode) or wrapping `Z.grid` directly (grid mode)
makes its column an indefinite-height ancestor, and the viewport
collapses to a sliver well under the intended bound.
- Grid cells set `width` / `height` (not `minWidth` / `minHeight`) on
the panel — `Z.grid`'s content-driven column sizing reads each
child's `width` style to pick a cell size, and a `minWidth`-only cell
measures as 0 there.
- An empty filtered view renders a single muted "No items" label
instead of an empty scroll area (table mode keeps the header above
it).
- Handlers (click, double-click, keyboard nav, column sort) are
registered once per widget id (deduped via `app._dataViewHandlers`);
item list, filter, column and callback swaps land without
re-registering, mirroring `selectableList`'s handler dedup.
- `props.commands` (list/table mode only — see below) adds a right-
click command menu, driven by `modules.api.editor.commands`. Each
entry is a command id (`Commands.get(id).title` labels it,
`Commands.isEnabled(id, ctx)` gates it); a `"-"` entry is a
separator. Right-clicking a row selects it alone first if it wasn't
already part of the selection, then opens the menu anchored to that
row. `ctx` passed to `enabledWhen`/`run` is `Selection.context()`
(this DataView's selection scope, since selecting the row focuses
it) plus `view` (this widget's id) and `item` (the row's underlying
item). Clicking an entry runs the command and closes the menu;
clicking elsewhere or Escape also closes it (`Z.popup` auto-dismiss).
- Grid mode does not support `props.commands` — its cells are `Z.panel`s
(generic `onClick` only), and `onPointerDown` (right-click sensing)
is decoded by the Canvas widget type alone.
# sides
Two-slot horizontal row that anchors the first child left, the second
child right, and stretches a flexible gap between them. Designed for
window title bars, toolbars, status footers — any "title left,
controls right" pattern.
## Exports
- `sides(opts: SidesOpts?) -> any` — build a two-slot left/right row. The module returns this function directly.
Types:
- `SidesOpts = { left: any?, right: any?, [number]: any, id: string?, style: { [string]: any }? }`
## Usage
```luau
local sides = require("@builtin::modules.zui.widget.sides")
-- Positional shape
sides({ leftWidget, rightWidget })
-- Named-slot shape
sides({
left = Z.hbox({ Z.lbl("◆"), Z.lbl("zero") }),
right = Z.hbox({ Z.iconBtn("─"), Z.iconBtn("□"), Z.iconBtn("✕") }),
})
```
## Notes
- When more than two widgets are needed on either side, wrap them in a
layout container first (`Z.hbox` / `Z.vbox`).
- Implemented as an `hbox` containing `[left, flex(), right]`; the
flex-grow spacer makes the right slot hug the row's right edge.
- Children render in source order on both sides.
# iconBtn
Button rendered with the phosphor glyph font. Convenience wrapper over
`Z.btn` for the common case of an icon-only button — defaults the
`fontFamily` to `phosphor` so callers don't repeat it on every click
target.
## Exports
- `iconBtn(iconText: string, callbackId: string, opts: table?) -> WidgetNode` — module returns the builder function directly.
`opts` is forwarded to `Z.btn`; this wrapper only injects
`opts.style.fontFamily = "phosphor"` when none is set.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.iconBtn("", "save:click", { id = "save", tooltip = "Save" })
```
## Notes
- Equivalent to `Z.iconButton` (which simply re-requires this module).
- The wrapper mutates the supplied `opts` table in place. If you reuse
the same `opts` across calls, expect `style.fontFamily` to persist.
- Any explicit `opts.style.fontFamily` wins; the default only fires
when the field is missing.
# hotbar
Game-HUD hotbar. Composes an hbox of per-slot buttons with overlaid
count + slot-index labels, highlighting the slot indicated by
`selectedSlot`. Clicking a slot fires the callback
`<opts.id or "hotbar">-<index>`. The caller is the source of truth
for the selection (controlled-component pattern).
## Exports
- `hotbar(slots: {Slot}, selectedSlot: number?, opts: HotbarOpts?) -> WidgetNode` — module returns the builder function directly.
Per-slot shape:
- `Slot = { icon: string?, count: number?, tooltip: string? }`
Options:
- `id: string?` — callback prefix. Default `"hotbar"`.
- `style: table?` — hbox style. Default `{ gap = 4 }`.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.hotbar(state.slots, state.selectedSlot, { id = "hotbar" })
-- Pattern-match the per-slot callbacks:
app:on("^hotbar%-(%d+)$", function(_, _, idx)
state.selectedSlot = tonumber(idx)
end)
```
## Notes
- Slot indices start at 1.
- Empty `slots` (or `nil`) renders an empty hbox.
- The widget is stateless — the parent app owns selection state.
- Each button's `id` is `<prefix>-slot-<i>`; the click callback is
`<prefix>-<i>` (note: different separator pattern by design).
# sep
Horizontal separator — a Luau builder over `canvas` with `fillWidth`
+ `"N%"` coords. Emits a single `kind = "line"` command spanning
`[0, mid]` -> `["100%", mid]`. `fillWidth = true` makes the canvas
claim the parent's full available width. 4 px of vertical breathing
room is baked in (thickness + 4) so the line has a clear top/bottom
gap.
## Exports
- `sep(opts: SepOpts?) -> any` — build a horizontal-line separator widget. The module returns this function directly.
Types:
- `SepOpts = { id: string?, classes: (string | { string })?, style: { [string]: any }? }`
## Usage
```luau
local sep = require("@builtin::modules.zui.widget.sep")
sep()
sep({ style = { color = "#0E639C", height = 2 } })
```
## Notes
- Style overrides: `style.height` (default 1.0) sets line thickness in
pixels, `style.color` (default `#3C3C3C`) sets line color.
- Pure builder — no side effects, no state.
# scene
Pan/zoom 2D viewport. Wraps `egui::containers::Scene` (added 0.34).
Children render inside a transformed coordinate space — mouse-wheel
zooms, primary-drag pans (right-click stays free for context menus).
Transform state persists in egui's per-id temp data, so pan/zoom
carries across frames without caller plumbing.
## Exports
- `scene(opts: SceneOpts?) -> any` — build a pan/zoom 2D viewport widget. The module returns this function directly.
Types:
- `SceneOpts = { id: string?, props: { [string]: any }?, style: { [string]: any }?, children: { any }?, zoomRange: { number }?, initialPan: { x: number, y: number }?, initialZoom: number?, onTransformChange: string?, focusable: boolean? }`
## Usage
```luau
local scene = require("@builtin::modules.zui.widget.scene")
scene({
zoomRange = { 0.25, 4.0 },
onTransformChange = "graph:moved",
children = {
Z.canvas({ commands = drawNodes(state) }),
Z.area({ id = "node-1", pos = nodePos[1] }, { ... }),
},
})
```
## Notes
- `zoomRange` defaults to `[0.25, 4.0]` (overriding egui's default of
`0.0..=1.0` so wheel-zoom works in both directions).
- `initialPan` and `initialZoom` only apply on the first frame; once
the user pans or zooms the persisted rect takes over.
- `onTransformChange` payload is a comma-separated string
`"panX,panY,zoom"` — UiValue is scalar-only. Parse with
`string.split(value, ",")`. Falls back to `<id>-transform` when not
set.
- Pan is primary-button only — right-click stays free for
`Z.contextMenu` handlers on inner widgets.
# dragValue
DragValue is a Luau builder over `canvas`. Emits a drag-to-change
numeric scrubber: background rect + centered numeric readout + an
`onDrag` handler that accumulates the per-frame delta scaled by
`opts.speed`. Range clamping + Shift-fine-drag (10× finer) ride on top.
Auto-registers `<id>:drag` per id (deduped) and dispatches
`opts.onChange` (or the widget id) with `{ value = newNumber }` on
every drag tick.
## Exports
- `dragValue(id: string, value: number?, lo: number?, hi: number?, opts: DragValueOpts?) -> WidgetNode` — build the scrubber canvas. Default range is `[0, 1]`; default speed is `0.01`; default format is `%.2f`.
Types:
- `DragValueOpts = { style: DragValueStyle?, width: number?, height: number?, speed: number?, format: string?, onChange: string?, label: string?, classes: (string | { string })?, app: any? }`
- `DragValueStyle = { width: number?, height: number?, background: string?, color: string?, borderColor: string?, borderRadius: number?, fontSize: number?, [string]: any }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.dragValue("plug:gain", state.gain, -24, 24, {
speed = 0.1,
format = "%.1f",
onChange = "plug:gain", -- defaults to widget id
})
```
## Notes
- Live value lives in `ui.widgetState(id, "value")`; the first render
seeds it from the caller's `value` arg.
- Inside a `Z.app` context the drag handler is auto-registered and
deduped per id. Outside, the canvas's `onDrag` is routed directly to
the user's callback id (legacy raw-onCallback mode) — the handler
receives the structured drag payload rather than a scalar.
- Inline text-edit on double-click is deferred. If a use case needs
typing, swap to `Z.input` via widgetState.
# input
Text input widget. Single-line by default; pass `multiline = true`
for a textarea. The code-editor variant (syntax-highlight, line
numbers, folding) lives in `zui.widget.codeEditor` — same underlying
widget kind, more defaults, plus the Luau highlighter producing
`segments`.
## Exports
- `input(id: string, value: string?, opts: InputOpts?) -> WidgetNode` — module returns the builder function directly.
Options forwarded to the node's `props`:
- `placeholder: string?`
- `onChange: string?` — callback id
- `submitOnEnter: boolean?`
- `multiline: boolean?`
- `password: boolean?`
- `codeEditor: boolean?`
- `segments: { Segment }?` — pre-highlighted layout segments
- `foldLanguage: string?` — opt into the Rust fold detector (`"lua"` etc.)
- `lineNumbers: boolean?`, `folding: boolean?`
- `props`, `classes`, `style` — standard widget plumbing.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.input("name", state.name, {
placeholder = "Your name",
onChange = "name:edit",
})
-- Multiline:
Z.input("notes", state.notes, { multiline = true })
```
## Notes
- When `props.segments` is supplied, the renderer builds the LayoutJob
from segments instead of the plain `text` string — but `text` is
still set, so the underlying value continues to work for callbacks.
- For syntax-highlighted code editing, prefer `Z.codeEditor`, which
bundles `foldLanguage`, `lineNumbers`, `folding`, and a default
highlighter call.
- Stateless — the parent owns the buffer; this widget only renders.
# area
Free-positioned area. Root-only — must be the top-level widget of its
registered screen. The `pos` field is at the widget root level (not
inside `props`) because that's what the engine's `area` decoder expects.
Pass `movable = true` to let the user drag the area; the dragged
position is held in egui's persistent memory.
## Exports
This module returns the widget factory directly. There is no returned
table.
- `area(children: { Node }?, opts: AreaOpts?) -> Node` — build a free-positioned area node.
Types:
- `Pos = { number }` — `{ x, y }` in screen pixels.
- `Pivot = string` — engine-defined anchor name (`"center"`, `"topleft"`, ...).
- `Node = { [string]: any }` — opaque widget node.
- `AreaOpts = { id: string?, pos: Pos?, pivot: Pivot?, movable: boolean?,
interactable: boolean?, style: { [string]: any }? }`
## Usage
```luau
local area = require("@builtin::modules.zui.widget.area")
local hud = area({
Z.btn("Drag me"),
}, {
pos = { 100, 100 },
movable = true,
interactable = true,
})
ui.registerScreen("draggable", hud)
```
## Notes
- The widget MUST be the top-level node of its registered screen.
- `pos`, `pivot`, `movable`, `interactable`, `style` are written at the
widget root level (NOT inside `props`) — the engine's `area` decoder
reads them from there.
- Lua-side read-back of dragged position for movable areas is a planned
Rust extension; today the dragged position is held in egui's persistent
memory and is not exposed back to Luau.
- Each `nil` field is omitted from the resulting node so the engine sees
only the fields the caller explicitly set.
# colorSwatch
Click-only coloured swatch — a small (default 24×24) coloured rect
with a white border. Used as a click target for material chips,
palette swatches, or any visual indicator that doubles as a click
target. Implemented as an empty-text `button` so the engine has no
dedicated arm.
## Exports
- `colorSwatch(color: any?, callbackId: any?, opts: ColorSwatchOpts?) -> WidgetNode` — build a coloured-swatch widget node.
Types:
- `ColorSwatchOpts = { id?, size?, style?, border?, borderWidth?, tooltip? }`
## Usage
```luau
local colorSwatch = require("@builtin::modules.zui.widget.colorSwatch")
colorSwatch("#ff8855", "swatch:click", { id = "row5" })
colorSwatch("#5588ff", "pick", { size = 32, tooltip = "Pick blue" })
```
## Notes
- Defaults: `size = 24`, `border = "#ffffff"`, `borderWidth = 1`, `borderRadius = 4`, `padding = 0`.
- Click handling reuses the button widget — same callback-id semantics, same closure auto-wiring inside `Z.app`.
- Per-swatch routing pattern: encode the swatch identity into the callback id (e.g. `"swatch-" .. hex`) and match with `app:on("^swatch%-(.+)$", ...)`.
# codeEditor
Code-editing variant of `input`. Defaults `multiline = true`,
`codeEditor = true`, `language = "lua"`, `lineNumbers = true`. The
`language` opt dispatches to a Luau highlighter from `zui.highlight.*`;
the resulting segments are passed to TextInput as `props.segments` so
the renderer builds a coloured LayoutJob without a Rust-side
highlighter call.
## Exports
- `codeEditor(id: string?, value: any?, opts: CodeEditorOpts?) -> WidgetNode` — build a code-editor widget node.
Types:
- `CodeEditorOpts = { multiline?, codeEditor?, lineNumbers?, folding?, language?, foldLanguage?, segments?, ... }` — accepts any opts the underlying `input` widget supports.
## Usage
```luau
local codeEditor = require("@builtin::modules.zui.widget.codeEditor")
codeEditor("editor1", "local x = 1\n", { language = "lua" })
codeEditor("editor1", code, { language = "lua", folding = true })
```
## Notes
- Folding is opt-in via `folding = true`. When combined with `language = "lua"`, the wrapper sets `foldLanguage = "lua"` so the gutter UI runs the Rust-side fold detector on the editor's live text. Other languages can't fold (no detector implemented yet).
- `opts.language` is consumed by the wrapper and never reaches `input.module`; the highlighter output lands in `opts.segments` instead.
- The engine's built-in highlighters have been deleted — language→segments mapping lives entirely in Luau.
# tabs
Tab strip — a horizontal row of buttons with one highlighted as
active. Each tab emits the callback id `<onChange>-<key>` so a single
pattern handler can capture every click and update the active tab in
state. Under a `Z.app` context the strip also intercepts arrow-key
navigation (with wrap-around, skipping disabled tabs) and dispatches
the same event a click would.
## Exports
- `tabs(items, activeKey, onChange, opts?) -> Node` — returns the tab-strip widget node. Module returns the builder function directly.
Types:
- `TabItem = { key: string, label?: string, icon?: string, enabled?: boolean }`
- `TabsOpts = { id?, app?, padding?, gap?, minTabWidth?, containerPadding?, style? }`
## Usage
```luau
local tabs = require("@builtin::modules.zui.widget.tabs")
local items = {
{ key = "entities", label = "Entities" },
{ key = "logs", label = "Logs" },
}
tabs(items, state.activeTab, "main-tabs")
app:on("^main%-tabs%-(.+)$", function(_v, _id, key)
state.activeTab = key
end)
```
## Notes
- The wrapper emits `{ type = "tabs", ... }`; the
`Z.defineWidget("tabs", ...)` registration in `zui.module/init.luau`
decodes that into the primitive `hbox` + `btn` tree the Rust
renderer sees.
- `activeKey` and `items` are mirrored into `widgetState` so the
per-strip key handler reads the live values at event time — closures
never go stale across re-renders.
- Tab itself stays the focus-traversal key (egui's built-in cycle);
only arrow keys are intercepted by the strip.
# label
Label widget — read-only text. Accepts top-level shortcuts for the
most commonly styled fields (`color`, `fontSize`, `bold`, `bg`,
`padding`, `minWidth`, `font`) so callers don't have to nest
everything under `style = { ... }`.
## Exports
- `label(text: any, opts: LabelOpts?) -> WidgetNode` — module returns the builder function directly. Also exposed as `Z.lbl`.
Options (top-level shortcuts mapped onto `style`):
- `color: string?`
- `fontSize: number?`
- `fontWeight: string?`
- `bold: boolean?` — sets `fontWeight = "bold"` when true.
- `bg: string?` — sets `style.background`.
- `padding: any?`
- `minWidth: number?`
- `font: string?` — sets `style.fontFamily`.
- `id`, `classes`, `class`, `style` — standard widget plumbing.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.lbl("Hello")
Z.lbl("dim text", { color = "#888", bold = true })
Z.lbl(42, { fontSize = 18, font = "monospace" })
```
## Notes
- `text` is coerced to a string via `tostring(text or "")`. Passing `nil`
yields the empty string.
- The shortcut props overwrite any conflicting field on `style`.
- Stateless — no module state, no engine calls.
# tree
Recursive expandable tree. Each node renders as a clickable row
(chevron + optional icon + label, indented by `depth * indentPx`, with
optional right-side action buttons). Clicking the chevron emits
`<onToggle>-<key>`; clicking the label emits `<onSelect>-<key>`. The
tree itself is stateless — toggle/select state lives in the caller's
state.
## Exports
- `tree(nodes, opts?) -> Node` — render a list of root nodes into a vbox of rows. Reached via the module's `__call` metamethod.
- `tree.fromFlat: (items, opts?) -> (roots, nodeById)` — re-exports the `fromFlat` sub-module that builds recursive nodes from a flat parent-pointer list.
Node shape:
- `{ key, label, children?, expandable?, expanded?, icon?, actions?, payload? }`
## Usage
```luau
local Z = { tree = require("@builtin::modules.zui.widget.tree") }
local function buildTree()
return Z.tree(state.nodes, {
onSelect = "ent-select",
onToggle = "ent-toggle",
selectedKey = state.selectedId,
})
end
app:on("^ent%-select%-(.+)$", function(_v, _id, key)
state.selectedId = key
end)
app:on("^ent%-toggle%-(.+)$", function(_v, _id, key)
state.expanded[key] = not state.expanded[key]
end)
```
## Notes
- The module returns a callable table — `tree(...)` works as a
function, and `tree.fromFlat(...)` reaches the sub-module. This is a
dynamic-dispatch shape; the `typed function` directive is skipped
here per the module-conversion spec rule 7.
- A row gets a chevron when it has children OR when `expandable ==
true` (lazy-load: the caller fetches children on the toggle event).
- `actions` callback ids are emitted verbatim — no key suffixing — so
a row-level action ("× delete") reads as the same callback whether
invoked from the tree, a context menu, or a toolbar.
- Hard depth cap (32) protects against accidentally cyclic node data.
# graph
Sparkline-class chart built on `canvas`. Bar mode emits N rect
commands; line mode emits a single polyline. Y axis auto-ranges from
the data unless the caller passes `minValue` / `maxValue`. Optional
gridlines + axis labels via the shared `_axes` helper; optional
on-hover tooltip pinned to the nearest data point. Plot has the more
featureful chart — graph is the cheap visual.
## Exports
- `graph(data: { number }?, opts: GraphOpts?) -> WidgetNode` — build the chart canvas. Empty data arrays render just the background; 1-element arrays render the background (the polyline path needs 2+ points).
Types:
- `GraphOpts = { id: string?, classes: (string | { string })?, style: { [string]: any }?, width: number?, height: number?, bg: string?, background: string?, color: string?, graphType: string?, minValue: number?, maxValue: number?, showAxes: boolean?, gridlines: boolean?, tooltip: boolean?, gridColor: string?, axisColor: string?, labelColor: string?, labelSize: number?, xLabels: boolean?, yLabels: boolean?, xTickFormat: any?, yTickFormat: any?, label: string?, app: any? }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.graph({ 1, 4, 9, 16, 25, 36 }) -- line, auto-range
Z.graph(samples, { graphType = "bar", color = "#7AA8FF" })
Z.graph(samples, { minValue = 0, maxValue = 1 })
Z.graph(samples, { id = "fps", tooltip = true }) -- hover tooltip
Z.graph(samples, { showAxes = true, gridlines = true })
```
## Notes
- `tooltip` requires `opts.id` — the hover handler stashes the active
index in `ui.widgetState(id, "hover")`. The tooltip persists until
the next hover; the engine doesn't emit pointer-leave for canvases.
- When `showAxes` is set, the plot body shrinks to reserve room for
tick labels. With axes off, the polyline spans the full canvas
height (preserved by the `respects explicit minValue/maxValue range`
test).
- DOM mirror exposes the resulting canvas as
`<canvas role="img" aria-label="Chart">` via the generic prop
pass-through.
# window
Floating window. Root-only — must be the top-level widget of its
registered screen. Pass `movable`, `resizable`, `closable`, `pos`
(default position, only honored on first frame), and `onClose` to wire
the close button.
## Exports
- `window(title, children, opts?) -> Node` — returns the window widget node. Module returns the builder function directly.
Types:
- `WindowOpts = { id?, movable?, resizable?, closable?, pos?, onClose?, props?, style? }`
## Usage
```luau
local window = require("@builtin::modules.zui.widget.window")
window("Inspector", { body }, {
movable = true,
closable = true,
onClose = "inspector:close",
})
```
## Notes
- Signature is `window(title, children, opts)`; the older
`window(children, opts)` shape now warns loudly via `log.warn`
instead of falling back silently to the default `"Window"` header
(closes #2342).
- The decoder also accepts the raw form
`{ type = "window", title = "...", children = {...} }`; `title` is
promoted into `props.title` automatically.
- `pos` is only honored on first frame — subsequent renders preserve
the user's drag position so callers never fight egui for window
placement.
# spacer
Fixed-size gap widget. Default 4 px. Use `Z.flex()` for a flex-grow
spacer that pushes siblings apart.
## Exports
- `spacer(n: number?) -> any` — build a fixed-size spacer widget. The module returns this function directly.
## Usage
```luau
local spacer = require("@builtin::modules.zui.widget.spacer")
spacer() -- 4 px gap
spacer(12) -- 12 px gap
```
## Notes
- Pure builder — no side effects, no state. Returns a widget table the
zui renderer understands directly.
- Use `Z.flex()` when you need a spacer that grows to fill remaining
space rather than a fixed size.
# hbox
Horizontal layout container. Children laid out left-to-right; spacing
via `style.gap`; alignment via `style.align` (`"start"` | `"center"` |
`"end"`).
## Exports
- `hbox(children: { WidgetNode }?, opts: HboxOpts?) -> WidgetNode` — build a horizontal-layout container.
Types:
- `HboxOpts = { id: string?, classes: (string | { string })?, props: { [string]: any }?, style: { [string]: any }? }`
- `WidgetNode = { [string]: any }` — widget table (interoperable with hand-written widget trees).
## Usage
```luau
local Z = require("@builtin::modules.zui")
local row = Z.hbox({ Z.lbl("a"), Z.lbl("b") }, {
style = { gap = 6, align = "center" },
})
```
## Notes
- Pure builder over `modules.zui.widget.node` — no state, no engine
calls. Safe at module load.
- `children` defaults to `{}` when nil so an empty hbox is a no-op.
- `style.gap` is the inter-child spacing in pixels; `style.align`
controls cross-axis alignment.
# icon
Icon glyph rendered via a glyph font (default `phosphor`). Effectively
a `label` with the `fontFamily` fixed to the icon font, kept as its
own widget so callers don't have to remember which fontFamily the
icons live under.
## Exports
- `icon(text: string, opts: IconOpts?) -> WidgetNode` — module returns the builder function directly.
Options:
- `font: string?` — override the font family. Default `"phosphor"`.
- `size: number?` — sets `style.fontSize`.
- `color: string?` — sets `style.color`.
- `padding: any?` — sets `style.padding`.
- `id: string?`, `style: table?` — standard widget plumbing.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.icon("", { size = 16, color = "#dcdcdc" })
Z.icon("", { font = "fontawesome" })
```
## Notes
- Stateless — returns a fresh widget table each call.
- `text` is whatever the icon font renders for the codepoint you pass.
- Top-level shortcuts (`size`, `color`, `padding`, `font`) overwrite any
conflicting field on `style`.
# plot
Plot is a Luau builder over `canvas` interaction props (`onDrag`,
`onScroll`, `onPointerMove`). Rebuilds the pan/zoom/legend/crosshair
UX from canvas primitives so there is no `egui_plot` dependency. The
module returns a callable table — `Z.plot(id, spec, opts)` builds the
widget, while `.line` / `.points` / `.heatmap` / `.boxPlot` /
`.bezierLine` are element helpers that tag specs with the right `type`.
## Exports
- `Plot.line(spec: LineSpec) -> LineSpec` — tag a spec with `type = "line"`.
- `Plot.points(spec: PointsSpec) -> PointsSpec` — tag a spec with `type = "points"`.
- `Plot.heatmap(spec: HeatmapSpec) -> HeatmapSpec` — tag a spec with `type = "heatmap"`.
- `Plot.boxPlot(spec: BoxPlotSpec) -> BoxPlotSpec` — tag a spec with `type = "boxPlot"`.
- `Plot.bezierLine(p0, p1, p2, p3, samples?, opts?) -> LineSpec` — sample a cubic bezier into a `LineSpec`.
- `__call(id, plotSpec, opts) -> any` — calling the table builds the plot widget itself.
Types:
- `Point = { number }` (2-element)
- `LineSpec = { type?, id?, name?, color?, points?, y?, width?, gradient?, fillY?, fillColor?, lineStyle? }`
- `PointsSpec = { type?, id?, name?, color?, points?, shape?, radius?, filled? }`
- `HeatmapSpec = { type?, id?, name?, values?, cols?, palette?, showLabels? }`
- `BoxPlotSpec = { type?, id?, name?, boxes?, horizontal? }`
- `PlotOptions = { showAxes?, showGrid?, showCrosshair?, invertX?, invertY?, legend?, showCoordinates?, coordinatesCorner?, allowZoom?, allowDrag?, allowScroll?, linkGroup?, linkAxis?, linkCursor?, xTickFormat?, yTickFormat? }`
- `PlotSpec = { options?: PlotOptions, elements?: { any } }`
- `PlotOpts = { width?, height?, style?, background?, classes?, label?, app? }`
## Usage
```luau
local Plot = require("@builtin::modules.zui.widget.plot")
local widget = Plot("chart-1", {
options = { showGrid = true, legend = { show = true } },
elements = {
Plot.line({ points = { { 0, 0 }, { 1, 0.5 }, { 2, 0.2 } }, name = "A" }),
Plot.points({ points = { { 1, 0.5 } }, radius = 6 }),
},
}, { width = 480, height = 240 })
```
## Notes
- Pan/zoom state lives in `ui.widgetState(id, ...)`; the same `id`
across renders preserves the viewport.
- `linkGroup` syncs pan/zoom across multiple plots — set `linkAxis`
/ `linkCursor` per axis to opt each plot in.
- Shift+Drag triggers boxed-zoom marquee selection; non-shift drag pans.
- `xTickFormat` / `yTickFormat` accept `function(v: number) -> string`
to override the default D3 nice-tick labelling.
- Heatmap `cols` defines column count — rows are inferred from
`#values / cols`.
# progressBar
Horizontal progress bar — a Luau builder over `canvas` with
`fillWidth` + `"N%"` coords. Emits two stacked `kind = "rect"`
commands: a full-width track plus a percent-of-width fill clipped to
the value. `value` is clamped to `[0, 1]` so out-of-range inputs don't
paint outside the canvas.
## Exports
- `progressBar(value: number?, opts: Opts?) -> any` — build the canvas widget node. Returned directly by the module.
Types:
- `Style = { height?: number, color?: string, borderRadius?: number }`
- `Opts = { id?: string, classes?: any, color?: string, trackColor?: string, style?: Style }`
## Usage
```luau
local progressBar = require("@builtin::modules.zui.widget.progressBar")
local widget1 = progressBar(0.42)
local widget2 = progressBar(value, { color = "#7BC97B", trackColor = "#222" })
```
## Notes
- A11y: the canvas declares `role = "progressbar"` and ARIA value
attrs (`ariaValueNow`, `ariaValueMin = 0`, `ariaValueMax = 1`) so
the DOM mirror exposes the same shape as a native `<progress>`.
- `style.borderRadius` defaults to `height / 2` for a pill silhouette.
Override to 0 for a sharp-cornered look.
- The fill rect's `max.x` is a `"N%"` string — width resolves at
paint time via `fillWidth = true`, so the builder doesn't need to
know the canvas's pixel width at construction time.
# dropdown
Combobox-style dropdown — Luau builder over `Z.btn` + `Z.popup` +
`Z.selectableList`. Click-to-open, click-to-select, click-outside /
Escape closes. Full keyboard navigation: the inner SelectableList
handles ArrowUp/Down/Home/End + Enter/Esc, and the trigger button
accepts ArrowDown / Enter / Space to open via generalised `onKey`.
## Exports
- `dropdown(id: string, options: { string }?, selected: number?, opts: DropdownOpts?) -> WidgetNode` — build the trigger + popup. `selected` is the 1-based index of the active option; first call seeds `ui.widgetState(id, "selected")`.
Types:
- `DropdownOpts = { style: { [string]: any }?, classes: (string | { string })?, onChange: string?, pivot: string?, rowWidth: number?, rowHeight: number?, ariaLabel: string?, app: any? }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.dropdown("env", { "Dev", "Stage", "Prod" }, state.env, {
onChange = "env:change",
})
app:on("env:change", function(v)
state.env = v -- v is the new 1-based index
end)
```
## Notes
- ARIA roles: trigger is `role="combobox"` / `aria-expanded`; popup
hosts a listbox. Focus moves to the row matching the current
selection on open, and returns to the trigger on close.
- Per-id handlers (`<id>-trigger`, `<id>:triggerKey`, `<id>:commit`,
`<id>:dismiss`) are registered once and deduped — re-renders don't
accumulate handlers.
- `selected` is clamped to `[1, #options]` so out-of-range inputs
don't crash the popup.
# toggle
iOS-style on/off switch — a Luau builder over `canvas`. Draws a
rounded pill background, a white circle knob sliding along its axis,
and an optional text label next to it. Operable by mouse (click) and
keyboard (Tab to focus, Enter/Space to flip).
## Exports
- `toggle(label, id, checked, opts?) -> Node` — returns the toggle widget node. Module returns the builder function directly.
Types:
- `ToggleOpts = { style?, width?, height?, onColor?, offColor?, knobColor?, onChange?, classes?, ariaLabel?, app? }`
## Usage
```luau
local toggle = require("@builtin::modules.zui.widget.toggle")
toggle("Lights on", "lights", state.lights, { onChange = "lights:toggle" })
-- Without onChange, the callback id defaults to the widget id:
toggle("Bypass", "fx:bypass", state.bypass)
```
## Notes
- Under `Z.app` the internal `<id>:click` handler reads
`widgetState(id, "checked")`, flips it, and dispatches `onChange` (or
the widget id) with `{ value = newBool }`. The Router unpacks
`data.value` so `function(v) end` receives the new bool.
- Without `Z.app` the canvas's `onClick` routes directly to the
caller's callback id and the legacy `onCallback(id, _)` pattern
(caller flips its own local state) keeps working.
- DOM mirror surfaces as `<canvas role="switch" aria-checked="…">` for
screen-reader support.
# button
Clickable button widget. Emits a callback id (string) routed by the
engine to `component.onCallback`, or wires up a closure when called
inside a `Z.app` builder. Accepts top-level style shortcuts (bg,
color, fontSize, bold, padding, border, borderWidth, minWidth,
minHeight, tooltip, enabled).
## Exports
- `button(text: string?, callbackId: any?, opts: ButtonOpts?) -> WidgetNode` — build a button widget node. The 2nd arg may be a string callback id, a closure (auto-wired in `Z.app`), or an options table when no 3rd argument is provided.
Types:
- `ButtonOpts = { id?, classes?, class?, style?, onClick?, bg?, color?, fontSize?, bold?, padding?, border?, borderWidth?, minWidth?, minHeight?, tooltip?, enabled?, paint? }`
## Usage
```luau
local btn = require("@builtin::modules.zui.widget.button")
btn("Save", "save:click") -- string callback id
btn("Save", function() print("clicked") end) -- closure (inside Z.app)
btn("Save", { onClick = "save:click", bg = "#222" }) -- JS-style opts shape
```
## Notes
- Passing a closure outside a `Z.app` builder errors at builder time — closures can't cross the Luau↔Rust FFI boundary, so they only auto-wire inside an app router.
- The 2-arg `Z.btn(text, opts)` shape is supported: if slot 2 is a table and slot 3 is missing, the callback is pulled from `opts.onClick`. Guards against the silent-dead-button bug tracked by #3059.
- See `docs/guides/zui-cheatsheet.md` for the three callback patterns.
# modal
Top-layer modal dialog with backdrop dim, click-outside + Escape
dismissal. Wraps `egui::Modal` (added 0.30, refined through 0.34).
Root-only — the render arm dispatches at the screen-root path, like
Window. Pass `dismissible = false` to force a button-only answer.
## Exports
- `modal(opts: Opts?) -> any` — build the modal widget node. Returned directly by the module.
Types:
- `Opts = { id?, title?, open?, onClose?, dismissible?, focusable?, tooltip?, children?, props?, style? }`
## Usage
```luau
local modal = require("@builtin::modules.zui.widget.modal")
local widget = modal({
title = "Confirm",
open = state.dialogOpen,
onClose = "dialog:close",
children = {
Z.lbl("Are you sure?"),
Z.hbox({
Z.btn("Yes", "dialog:yes"),
Z.btn("No", "dialog:no"),
}),
},
})
```
## Notes
- Known limitation (pending Wave 5 / #2416): Tab navigation isn't
trapped inside the modal — keyboard focus can still reach widgets
behind the backdrop. The visible "this is modal" contract is
otherwise complete.
- Must render at the screen root for the backdrop to cover the whole
surface; nesting inside a `panel` will clip the dim layer.
- `dismissible = false` is the right choice for confirm dialogs whose
answer affects irreversible state.
# dockArea
A docking container backed by egui_dock. Holds a persistent `DockState` keyed by the widget id, so split/tab arrangements and user drags survive across frames. Children are `dockPanel`s, each supplying a tab id, a title, and a content subtree.
The dockArea must be the root of a registered screen tree. Each frame it reconciles: it adds tabs for newly-present dockPanel ids and removes tabs whose dockPanel disappeared. Closing a tab emits a `<panelId>-close` interaction so the app can drop the panel from its children.
The module returns the widget builder function directly.
## Builder
`dockArea(opts?) -> widget node`. `opts` fields:
- `id` — the widget id the DockState is keyed by.
- `layout` — a serialized `DockState` JSON string that seeds the split/tab arrangement on first render. Read the live arrangement back with `ui.getDockLayout(id)` and pass it here to restore.
- `restoreLayout` / `restoreEpoch` — re-apply a saved layout to an already-live dock: pass the JSON in `restoreLayout` and bump `restoreEpoch` to a new value; the dock re-deserializes once per new epoch, then stays freely draggable.
- `overlayType` — `"widgets"` (icon drop buttons) or `"highlightedAreas"` (quadrant highlights — edges split, middle ring appends as a tab, dead-center floats the tab into its own window).
- `leafCollapseButtons` / `leafCloseAllButtons` — the collapse arrow and close-all button on each leaf's tab bar.
- `allowedSplits` — how a dragged tab resolves over a drop area: `"all"`, `"none"`, `"leftRight"`, `"topBottom"`.
- `children` — the dockPanels.
- `props`, `style` — the `style` block carries the standard widget keys (`borderWidth`, `borderRadius`, `padding`) plus the dock-specific chrome keys typed as `ZuiDockStyle`.
## Usage
```luau
local dockArea = require("modules.zui.widget.dockArea")
local widget = dockArea({ id = "editor", children = {
Z.dockPanel({ id = "scene", title = "Scene",
children = { Z.lbl("Scene body") } }),
Z.dockPanel({ id = "props", title = "Properties",
children = { Z.lbl("Inspector body") } }),
}})
```
# checkbox
Check-mark style boolean input with a trailing label.
## Exports
- `checkbox(label: string?, id: string?, checked: any?, opts: CheckboxOpts?) -> WidgetNode` — build a checkbox widget node.
Types:
- `CheckboxOpts = { props?, onChange?, style? }`
## Usage
```luau
local checkbox = require("@builtin::modules.zui.widget.checkbox")
checkbox("Enabled", "cb1", true, { onChange = "cb1:change" })
```
## Notes
- Initial `checked` is coerced via `value == true` — anything that isn't strictly `true` becomes `false`.
- `onChange` receives a `ValueChanged(boolean)` event when the user toggles the box.
# canvas
Immediate-mode 2D-paint widget. The body is a sequence of paint
commands (`line`, `bezier`, `polyline`, `rect`, `circle`, `text`)
drawn in widget-local coordinates with origin at the top-left and +y
going down (egui-standard, opposite of plot). Wires through pointer,
drag, double-click, key, and scroll handlers, and forwards `role` /
`tag` / `aria*` props for accessibility.
## Exports
- `canvas(id: string?, opts: CanvasOpts?) -> WidgetNode` — build a canvas widget node from a list of paint commands plus optional interaction handlers.
Types:
- `CanvasOpts = { commands?, width?, height?, onClick?, onPointerDown?, onPointerUp?, onPointerMove?, onDrag?, onDoubleClick?, onKey?, onScroll?, role?, tag?, style?, class?, classes? }`
## Usage
```luau
local canvas = require("@builtin::modules.zui.widget.canvas")
canvas("my-canvas", {
commands = {
{ kind = "line", a = {10,10}, b = {200,150}, color = "#7AA8FF", width = 2 },
{ kind = "circle", center = {100,100}, radius = 30, fill = "#FF8855" },
{ kind = "text", pos = {10,180}, text = "hello", color = "#fff", fontSize = 14 },
},
onClick = "canvas:click",
style = { width = 600, height = 400, background = "#161B22" },
})
```
## Notes
- Pointer events surface widget-local cursor coords as either a `"x,y"` string (legacy) or a structured Luau table (modern handlers).
- `onClick` / `onPointerDown` / `onPointerUp` carry `data.button` (0 = left, 1 = right, 2 = middle) identifying which mouse button triggered the event.
- `onKey` fires only while the canvas has keyboard focus; `onScroll` fires while it is hovered.
- Any `opts.aria*` key passes through verbatim — no wrapper-side allowlist needed.
- Command kinds and per-kind fields are defined in `crates/zero_ui_protocol/src/canvas.rs`.
# contextMenu
Convenience wrapper over `Z.popup` for the context-menu pattern — a
popup pinned to a widget you typically open on right-click. Same
ordering constraint as `Z.popup`: the anchor must render before the
menu in tree order.
## Exports
- `contextMenu(anchorId: string?, items: { any }?, opts: ContextMenuOpts?) -> WidgetNode` — build a context-menu popup attached to the anchor widget.
Types:
- `ContextMenuOpts = { id?, open?, onDismiss?, pivot?, focusable?, style? }`
## Usage
```luau
local contextMenu = require("@builtin::modules.zui.widget.contextMenu")
-- in your Z.app builder:
Z.btn("Item", "row1"), -- anchor
Z.contextMenu("row1", {
Z.btn("Cut", "ctx:cut"),
Z.btn("Copy", "ctx:copy"),
Z.btn("Paste", "ctx:paste"),
}, { open = menuOpen, onDismiss = "ctx:dismiss" })
```
## Notes
- Wave 3 ships caller-managed `open` state. Right-click sensing on interactive widgets (and self-toggling menus) is future work — for now, drive `open` yourself in `onClick` / `onCallback` handlers.
- Default pivot is `"belowLeft"`.
- The wrapper is a thin pass-through; any popup-supported field can be reached by adding it to the surrounding popup directly if needed.
# centralPanel
Central panel that fills the area not consumed by the docked edge
panels. Root-only — must be the body of a docked-shell layout, with
edge panels (e.g. `topPanel`, `bottomPanel`, `leftPanel`, `rightPanel`)
docked around it.
## Exports
- `centralPanel(children: { any }?, opts: { [string]: any }?) -> WidgetNode` — build a centralPanel widget node wrapping the given children.
## Usage
```luau
local centralPanel = require("@builtin::modules.zui.widget.centralPanel")
centralPanel({
heading("Body"),
-- ...
})
```
## Notes
- Root-only — the renderer expects it at the top level of a screen, alongside any docked edge panels.
- Passes `opts` straight through to the underlying `node` constructor; no widget-specific shortcuts.
# statusLabel
Numeric label that auto-picks a class by threshold ranges. The caller
passes `{ good = X, warn = Y }` (sorted ascending). Values below `good`
get the `good` class; below `warn` get `warn`; the rest get `bad`.
Non-numeric values get the `bad` class so a missing/erroring data
source is visually loud. Defaults to the engine theme's
`debug-timing-good/warn/bad` classes shipped in `themes/debug.yaml`.
## Exports
- `statusLabel(value, thresholds, opts?) -> Node` — returns a coloured label widget. Module returns the builder function directly.
Types:
- `StatusClasses = { good: string, warn: string, bad: string }`
- `StatusThresholds = { good: number?, warn: number? }`
- `StatusLabelOpts = { id?, classes?, ratioOf?, fmt?, bold?, color?, fontSize? }`
## Usage
```luau
local statusLabel = require("@builtin::modules.zui.widget.statusLabel")
statusLabel(dtMs, { good = 16, warn = 33 })
statusLabel(used, { good = 0.8, warn = 0.95 }, {
ratioOf = max,
fmt = function(v) return string.format("%.1f%%", v * 100) end,
})
```
## Notes
- Pass `ratioOf` to compare `value / ratioOf` against the thresholds
while still feeding the *raw* value to `fmt`. Non-positive `ratioOf`
values are ignored.
- Pass custom `classes = { good, warn, bad }` to retarget styling. The
default classes live in `themes/debug.yaml`.
- Non-numeric `value` always renders in the `bad` class so a broken
data source is visually loud.
# leftPanel
Left-docked panel. Root-only — must be a top-level child of the
screen builder, not nested inside another container. Wraps the
supplied children in a `leftPanel` node that the layout engine docks
against the left edge of its parent screen.
## Exports
- `leftPanel(children: { WidgetNode }, opts: table?) -> WidgetNode` — module returns the builder function directly.
`opts` is forwarded verbatim to the underlying node.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.app(function()
return {
Z.leftPanel({
Z.lbl("Sidebar"),
-- ...
}),
Z.centralPanel({
-- main view
}),
}
end)
```
## Notes
- Must be a root-level child of the screen builder; nesting it inside
another container is unsupported.
- `nil` children are treated as an empty array.
- Stateless.
# flex
Flex-grow spacer. Pushes siblings apart inside an hbox/vbox — use to
right-align items in a horizontal row, etc. The engine treats a spacer
with no `space` set as flex-grow.
## Exports
- `flex() -> Spacer` — build a flex-grow spacer widget table.
Types:
- `Spacer = { type: string }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
local row = Z.hbox({ Z.lbl("left"), Z.flex(), Z.lbl("right") })
```
## Notes
- Pure function — no engine calls, no state. Safe at module load time.
- The returned table is interoperable with hand-written widget trees;
any container that accepts a `{ type = "spacer" }` child will treat
it as flex-grow.
# ZuiTerminal
A terminal pane widget. Renders the live vt100 grid of a terminal from the
engine's terminal registry (created with `terminal.create`, run with
`terminal.spawn`) inside the UI tree. Captures the keyboard while focused; the
scroll wheel and Shift+PageUp/PageDown page the scrollback. Backed by a host PTY,
so terminals run on native targets.
# split
Two-pane resizable split. `direction` is `"horizontal"` or
`"vertical"`; `firstSize` is the first pane's size in pixels (or
`0..1` for proportional). Children must be exactly two widgets.
## Exports
- `split(children: { any }?, opts: SplitOpts?) -> any` — build a two-pane resizable split widget. The module returns this function directly.
Types:
- `SplitOpts = { id: string?, direction: string?, firstSize: number?, props: { [string]: any }?, style: { [string]: any }? }`
## Usage
```luau
local split = require("@builtin::modules.zui.widget.split")
split({ leftWidget, rightWidget }, {
direction = "horizontal",
firstSize = 200,
})
```
## Notes
- `direction` defaults to `"horizontal"` when omitted.
- `firstSize` accepts pixels (>= 1) or a proportion in `0..1`.
- Children must be exactly two widgets — wrap multiple widgets in a
layout container if more pane content is needed.
# vbox
Vertical layout container. Children stacked top-to-bottom; spacing via
`style.gap`; horizontal alignment via `style.align` (`"start"` |
`"center"` | `"end"`).
## Exports
- `vbox(children, opts?) -> Node` — returns the vertical-layout widget node. Module returns the builder function directly.
Types:
- `VboxOpts = { id?, classes?, props?, style? }`
## Usage
```luau
local vbox = require("@builtin::modules.zui.widget.vbox")
vbox({ header, body, footer }, { style = { gap = 8, align = "center" } })
```
## Notes
- Pairs with `hbox` for horizontal layouts and `wrap`/`grid` widgets
for more complex flows.
- `style.gap` is the spacing between children, not padding around the
container — use `style.padding` for the latter.
# fs
Filesystem composite widgets — pure builders that turn VFS listing
data into widget trees. Four stateless pieces every caller composes
with their own state + polling.
## Exports
- `M.tree(roots, opts) -> WidgetNode` — folder tree (recursive) built on `Z.tree` with VFS-friendly defaults.
- `M.fileList(entries, opts) -> WidgetNode` — flat list of files; folders first; selected file highlighted.
- `M.preview(data, opts) -> WidgetNode` — file preview pane (text or note).
- `M.breadcrumb(path, opts) -> WidgetNode` — clickable path crumbs.
## Usage
```luau
local Fs = require("@builtin::modules.zui.widget.fs")
return Z.hbox({
Fs.tree(state.roots, { onSelect = "files-tree-select" }),
Fs.fileList(state.entries, { onSelect = "files-row", selectedKey = state.name }),
Fs.preview({ name = state.name, text = state.text }, { id = "files-preview" }),
})
```
## Notes
- All four builders are stateless: pass data in, get a widget tree
out. They never touch the VFS — the caller owns reads, listing, and
cache eviction.
- `Z.fs.*` is what Layer B (`engine/ui/file_manager`) and Layer C
(system_tools' "Files" tab) compose with their own polling. You can
build your own file-browser by composing these directly.
# meter
Peak-style level meter built on `canvas`. Renders either a continuous
fill or N segmented LED rects, plus an optional peak indicator line.
Read-only — no interaction props. Peak-hold state lives in
`ui.widgetState(id, ...)` when an `id` is supplied; without one, the
meter still renders but the peak indicator is skipped.
## Exports
- `meter(value: number?, opts: Opts?) -> any` — build the meter canvas node. Returned directly by the module.
Types:
- `Style = { width?, height?, background?, fillColor?, warnColor?, clipColor?, offColor? }`
- `Opts = { id?, orientation?, segments?, warnThreshold?, clipThreshold?, peakHoldMs?, peak?, width?, height?, background?, fillColor?, warnColor?, clipColor?, offColor?, style? }`
## Usage
```luau
local meter = require("@builtin::modules.zui.widget.meter")
local widget = meter(state.master_l, {
id = "master-meter-l",
orientation = "horizontal",
style = { width = 220, height = 8,
fillColor = "#3cc864", warnColor = "#dcc83c",
clipColor = "#dc463c", background = "#101214" },
})
```
## Notes
- `value` is clamped to `[0, 1.5]` so out-of-range inputs paint into
the clip-coloured band without spilling outside the canvas.
- Peak hold defaults to 1500ms then decays at 1.5/sec — matches the
deleted Rust `render_meter` algorithm.
- Pass `opts.peak` to override the computed peak — useful for
ganged-channel meters where a single source value drives multiple
visuals.
- Vertical default is 12×160; horizontal flips to 160×12.
# radioGroup
Luau builder for a single-select radio group composed of focusable
per-row canvases inside a panel. Each row draws its own background
highlight + circle glyph + label; click commits the selection,
arrow-keys cycle it. Wraps the rows in a panel with
`role="radiogroup"` so assistive tech reads it as a group.
## Exports
- `radioGroup(id: string, options: { any }?, selected: number?, opts: RadioGroupOpts?) -> any` — build a radio-group widget. The module returns this function directly.
Types:
- `RadioGroupOpts = { style: { [string]: any }?, classes: (string | { string })?, rowWidth: number?, rowHeight: number?, onChange: string?, app: any?, ariaLabel: string?, label: string? }`
## Usage
```luau
local radioGroup = require("@builtin::modules.zui.widget.radioGroup")
radioGroup("difficulty", { "Easy", "Normal", "Hard" }, state.diff, {
onChange = "ui:diff:change",
})
```
## Notes
- Single-select; `selected` is a 1-based index. The first call seeds
`ui.widgetState(id, "selected")` from the caller's arg; subsequent
ticks read state back from there.
- Keyboard nav (per-row canvas `onKey`): `ArrowDown` / `ArrowRight`
next, `ArrowUp` / `ArrowLeft` previous, `Home` -> 1, `End` ->
`#options`. Tab / Shift+Tab walk the per-row canvases via egui's
focus chain. Click also commits selection.
- `opts.onChange` is dispatched as a callback id on the surrounding
`Z.app` router with `data.value` set to the new 1-based index.
Without an app in scope, only `ui.widgetState` updates.
- Handlers are registered once per group id (deduped via
`app._radioGroupHandlers`); option-count and `onChange` swaps land
without re-registering.
# svg
Inline vector graphics widget. Wraps a single `svg` node that
rasterizes an SVG document at the element's display resolution, so it
stays crisp from a 16px line icon to a 600px chart. Sizing comes from
`style.width` / `style.height`; `opts.fit` chooses the viewBox mapping.
## Exports
- `svg(source: string?, opts: SvgOpts?) -> WidgetNode` — module returns the builder function directly.
Options:
- `id: string?` — widget id.
- `style: table?` — passes through to the underlying node; supply `width`/`height` here, and `color` to ink `fill="currentColor"`.
- `fit: string?` — viewBox mapping: `"meet"` (default, uniform + letterbox), `"none"`/`"stretch"` (fill both axes), `"slice"` (uniform + crop). `preserveAspectRatio` keywords (e.g. `"xMidYMid meet"`) are accepted too.
- `src: string?` — render a `.svg` texture-asset reference instead of an inline `source` string.
## Usage
```luau
local Z = require("@builtin::modules.zui")
-- Inline document; currentColor inks the element's CSS color.
Z.svg("<svg viewBox='0 0 24 24'><path d='M4 12h16' stroke='currentColor' stroke-width='2'/></svg>", {
style = { width = 24, height = 24, color = "#3b82f6" },
})
-- From a .svg asset file.
Z.svg(nil, { src = "@builtin::icons.logo", style = { width = 64, height = 64 } })
```
## Notes
- Stateless — returns a fresh widget table each call.
- `fill="currentColor"` resolves to the element's CSS `color`; `fill`/`stroke`
gradients, the full path grammar, basic shapes, `viewBox`,
`preserveAspectRatio`, and `<g>` grouping all render.
- Pass either an inline `source` string or `opts.src` — `source` is the
document text, `src` points at a `.svg` asset file.
- Rasterization happens at the resolved display size, so scaling the widget
re-renders sharp rather than sampling a fixed bitmap.
# section
Panel with a bold header label on top. Convenience composition — every
demo had its own version before this widget existed. The header style
defaults to a small accent label; override via `opts.headerStyle`.
## Exports
- `section(title: any, children: { any }?, opts: SectionOpts?) -> any` — build a panel with a bold header label. The module returns this function directly.
Types:
- `SectionOpts = { id: string?, classes: (string | { string })?, props: { [string]: any }?, style: { [string]: any }?, headerStyle: { [string]: any }?, titleColor: string?, bg: string?, border: string?, borderWidth: number?, padding: any?, gap: number?, minWidth: number?, minHeight: number?, maxWidth: number?, maxHeight: number? }`
## Usage
```luau
local section = require("@builtin::modules.zui.widget.section")
section("Settings", { Z.label("hello") })
```
## Notes
- The `title` is coerced via `tostring`, so non-string values are
accepted as headers.
- `opts.headerStyle` fully replaces the default header style — pass a
table containing every field you want set, not a partial override.
- All standard panel options (`bg`, `border`, `padding`, ...) flow
through to the wrapping panel.
# healthBar
Game-HUD horizontal health bar. Luau builder over `canvas` — emits a
background rect plus a foreground fill rect scaled by `current / max`,
with an optional centered numeric label. The wrapping canvas exposes
ARIA `progressbar` semantics for the DOM mirror so screen readers
report the current value.
## Exports
- `healthBar(current: number, max: number, opts: HealthBarOpts?) -> WidgetNode` — module returns the builder function directly. Call `Z.healthBar(...)` via the zui re-export.
Options:
- `showText: boolean?` — overlay `"current/max"` centered. Default `true`.
- `background: string?` — empty-bar tint. Default `"#3c1e1e"`.
- `color: string?` — fill color when `gradient` is not set. Default `"#c83c3c"`.
- `width: number?`, `height: number?` — bar size. Defaults `200 x 24`.
- `label: string?` — `aria-label` override. Default `"Health"`.
- `gradient: boolean | {{number, string}}?` — `true` for the default 3-stop red→yellow→green, or a sorted `{{ratio, "#rrggbb"}}` list.
- `id`, `classes`, `style` — standard widget plumbing.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.healthBar(state.hp, state.hp_max, {
showText = true,
gradient = true,
label = "Player health",
})
-- Custom gradient stops:
Z.healthBar(hp, max, {
gradient = {
{ 0, "#000000" },
{ 0.5, "#777777" },
{ 1, "#ffffff" },
},
})
```
## Notes
- Stops must be sorted ascending by ratio. Ratios outside `[0, 1]` are
clamped. Fewer than 2 stops falls back to the default 3-stop palette.
- The fill rect is omitted when `ratio == 0` so an empty bar's edge
doesn't render a 0-width sliver.
- `gradient` (when truthy) overrides `color`.
- Stateless — no module state, safe to call every frame.
# filterRow
Composite filter row — search input + toggle checkboxes + sort
dropdown + counter + action buttons. Captures the pattern every list
view re-implements (entities, logs, assets, scripts,
animation_browser). Caller owns all bound values; the widget is
stateless. Each piece is optional; an empty `opts` produces an empty
hbox.
## Exports
- `filterRow(opts: FilterRowOpts?) -> WidgetNode` — assemble the row from the optional pieces in `opts`. Callback ids fire as `<id>-search`, `<id>-toggle-<key>`, `<id>-sort`, plus each action's `onClick` verbatim.
Types:
- `FilterRowOpts = { id: string?, search: SearchOpts?, toggles: { ToggleOpts }?, sort: SortOpts?, counter: CounterOpts?, actions: { ActionOpts }?, searchClass: string?, toggleClass: string?, sortClass: string?, class: string?, gap: number?, marginBottom: number?, padding: any?, align: string? }`
- `SearchOpts = { value: any?, placeholder: string?, class: string?, minWidth: number? }`
- `ToggleOpts = { key: any, label: string?, value: boolean?, class: string? }`
- `SortOpts = { options: { string }?, selected: number?, class: string?, minWidth: number? }`
- `CounterOpts = { visible: number?, total: number?, class: string?, fontSize: number? }`
- `ActionOpts = { label: string?, onClick: string?, variant: string?, padding: { number }?, minWidth: number?, tooltip: string?, class: string? }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
return Z.filterRow({
id = "logs-filter",
search = { value = state.q, placeholder = "Search...", minWidth = 160 },
toggles = {
{ key = "info", label = "Info", value = state.showInfo },
{ key = "warn", label = "Warn", value = state.showWarn },
},
sort = { options = LOG_TYPES, selected = state.typeIdx },
counter = { visible = #filtered, total = #all },
actions = { { label = "Clear", onClick = "logs-clear", variant = "danger" } },
})
```
## Notes
- Stateless. Caller owns every bound value; the widget only routes
events through the `<id>-*` callback ids.
- When `counter` or `actions` are present, a flex spacer is inserted
first so they right-align to the trailing edge of the row.
- Action `variant` of `"primary"` / `"danger"` selects a coloured
background from the active theme; anything else falls back to
`theme.panel_alt`.
# image
Static image widget. Wraps a single `image` node whose `src` is fed
to the engine's image loader. Sizing comes from `style.width` and
`style.height`.
## Exports
- `image(src: string, opts: ImageOpts?) -> WidgetNode` — module returns the builder function directly.
Options:
- `id: string?` — widget id.
- `style: table?` — passes through to the underlying node; supply `width`/`height` here.
## Usage
```luau
local Z = require("@builtin::modules.zui")
-- By VFS path.
Z.image("/source/libs/@builtin/textures/uv_checker_bw.png", { style = { width = 64, height = 64 } })
-- By asset identity.
Z.image("@builtin::textures.uv_checker_bw", { style = { width = 64, height = 64 } })
```
## Notes
- Stateless — no side effects beyond producing a widget table.
- `src` reaches the engine's image loader, which resolves these spellings:
a VFS path (`/source/...` or `/zero/source/...`), an asset identity
(`@builtin::textures.uv_checker_bw`), an asset guid, and an http URL.
A path anchors at the VFS root, so name it from there.
- A `src` the loader cannot reach paints a `[failed <src>]` placeholder in
place of the image.
- Aspect ratio is whatever the engine's image renderer applies — this
widget does not impose one.
# scroll
Scrollable container. Pass `maxHeight` (or `maxWidth`) to bound the
visible region; children beyond that get scrolled. Top-level shortcuts
`maxHeight` / `maxWidth` / `minHeight` / `minWidth` / `stickToBottom`
merge into `style` / `props` so the common case doesn't require nesting
under `style = { ... }`.
## Exports
- `scroll(children: any?, opts: ScrollOpts?) -> any` — build a scrollable container. The module returns this function directly.
Types:
- `ScrollOpts = { id?, classes?, props?, style?, maxHeight?, maxWidth?, minHeight?, minWidth?, stickToBottom? }`
## Usage
```luau
local scroll = require("@builtin::modules.zui.widget.scroll")
-- Shortcut form (preferred):
scroll(rows, { maxHeight = 200 })
-- Equivalent verbose form:
scroll(rows, { style = { maxHeight = 200 } })
```
For tall content inside `Z.panel`, prefer `Z.panel(rows, { scroll =
true, maxHeight = N })` — see the [panel README](../panel.module/README.md)
for the pattern documented under issue #3270.
## Notes
- Either a single widget table or an array of widgets is accepted as
`children` — single widgets do not need to be wrapped in `{ ... }`
(closes #2331).
- Without a `maxHeight` / `maxWidth` (top-level or `style.*`), the
scroll area fills the parent's available rect and scrolls on overflow
(renderer applies `auto_shrink([false, false])` in that case). With a
bound, the scrollArea sizes to it and scrolls past that bound.
# diagnosticDetail
Detail pane for a single LSP diagnostic. Renders a severity / code
header, an optional `file:line:col` row (with optional copy-path
button), the message and suggestion, and an optional source snippet
around the offending line. Stateless builder — pass a diagnostic in,
get a widget tree out.
## Exports
- `diagnosticDetail(d: Diagnostic?, opts: DiagnosticDetailOpts?) -> WidgetNode` — module returns the builder function directly. Also reachable via `Z.lsp.diagnosticDetail`.
Options:
- `id: string?` — id forwarded to the outer panel.
- `source: string?` — full file contents. Enables the snippet panel.
- `snippetContext: number?` — lines of context around the focused line. Default `2`.
- `onCopyPath: string?` — callback prefix for the copy-path button. Emits `<prefix>-copy` when clicked. Omit to hide the button.
`d` shape (from `lsp.check*`): `{ severity, code?, path?, line?, col?, message?, suggestion? }`.
## Usage
```luau
local Z = require("@builtin::modules.zui")
Z.lsp.diagnosticDetail(diags[selected], {
source = source,
onCopyPath = "lsp:path",
})
```
## Notes
- When `d` is `nil`, renders a placeholder panel ("Select a diagnostic
to see details.") rather than crashing.
- The widget never copies to the clipboard itself — it only emits a
callback. The parent app decides what "copy" means in its host
(some have `ui.copy(text)`, others log to stdout).
- The snippet renders only when both `opts.source` and `d.line` are
provided.
- Severity colours come from `Theme.default` (`danger`, `warn`, `info`,
`text_dim`); unknown severities fall back to `theme.text`.
# diagnosticsList
Scrollable list of LSP diagnostics. Input shape matches the array
returned by `lsp.check()` / `lsp.checkAll().diagnostics` /
`lsp.checkDirty()`. Each row shows a severity dot, location
(`path:line:col`), code chip, and message. Clicking a row emits the
callback id `<onSelect>-<idx>` (1-based) so the owning app can route
it back into its selection state.
## Exports
- `diagnosticsList(diags: { Diagnostic }?, onSelect: string?, opts: Opts?) -> any` — build the scrollable widget. Returned directly by the module.
Types:
- `Diagnostic = { severity?: string, line?: number, col?: number, code?: string, message?: string, suggestion?: string, path?: string }`
- `Filter = { severity?: string, code?: string, pathSubstr?: string }`
- `Opts = { id?: string, filter?: Filter, maxRows?: number, messageMax?: number, maxHeight?: number, emptyMessage?: string }`
## Usage
```luau
local diagList = require("@builtin::modules.zui.widget.lsp.diagnosticsList")
local widget = diagList(lsp.check(), "lsp-row", {
filter = { severity = "error" },
maxRows = 50,
maxHeight = 320,
})
app:on("^lsp%-row%-(%d+)$", function(_v, _id, idx)
state.selected = tonumber(idx)
end)
```
## Notes
- `opts.maxRows` defaults to 200 — overflow paints a "+N more" footer
chip rather than rendering thousands of rows.
- Messages are truncated to `opts.messageMax` characters (default 200)
with an ellipsis so a single huge diagnostic doesn't push the layout.
- Severity colors flow from the active theme — `danger` / `warn` /
`info` / `text_dim` for `error` / `warning` / `info` / `hint`.
- The list is presentational only — it does NOT call `lsp.*`. The
caller supplies the diagnostics array (and re-renders with a new
array when the underlying check refreshes).
# directiveBadge
Colored chip showing a file's `--!nocheck` / `--!nolint` /
`--!nocheck:<codes>` directive state. Input is exactly what
`lsp.readDirectives(source)` returns: `{ mode = "none" | "all" | "lint" | "codes", codes? = {string} }`.
Returns `nil` when `mode == "none"` (no directive present) so the
caller can drop the result into a layout without conditionals
cluttering the call site. Pass `opts.showNone = true` to render a
neutral "no directive" chip for layouts that need the slot occupied.
## Exports
- `directiveBadge(skipMode: SkipMode?, opts: Opts?) -> any?` — build the chip. Returns `nil` for `mode == "none"` unless `opts.showNone` is set. Returned directly by the module.
Types:
- `SkipMode = { mode?: string, codes?: { string } }`
- `Opts = { id?: string, padding?: { number }, showNone?: boolean }`
## Usage
```luau
local directiveBadge = require("@builtin::modules.zui.widget.lsp.directiveBadge")
children[#children + 1] = directiveBadge(lsp.readDirectives(source))
-- or, always-occupy-slot variant:
children[#children + 1] = directiveBadge(d, { showNone = true })
```
## Notes
- The chip color tracks severity: `danger` for `--!nocheck` (all passes
skipped), `warn` for `--!nolint` and `--!nocheck:<codes>`.
- Tooltip text spells out exactly which passes / codes the directive
silences so an inspector view doesn't need a separate explanation
panel.
- Pure presentational — no side effects, no LSP calls. The caller is
responsible for refreshing the directive state when the source
changes.
# docBrowser
Three-pane API doc browser: namespace tree (left), method list
(middle), describe pane (right). State carries the current selection
plus a search query; the widget itself is stateless — the owning app
prefetches `lsp.namespaces()` / `lsp.methods(ns)` / `lsp.describe(path)`
and threads them through. Drives the LSP UI's "Docs" tab.
## Exports
- `docBrowser(state: State?, opts: Opts?) -> any` — build the three-pane split widget. Returned directly by the module.
Types:
- `State = { namespaces?, selectedNamespace?, methods?, selectedMethod?, describe?, searchQuery?, searchResults?, kindFilter? }`
- `Opts = { id?: string, onSelect?: string, sizes?: { number } }`
## Usage
```luau
local docBrowser = require("@builtin::modules.zui.widget.lsp.docBrowser")
local widget = docBrowser(state, { onSelect = "lsp-docs" })
app:on("^lsp%-docs%-ns%-(.+)$", function(_v, _id, ns) state.selectedNamespace = ns end)
app:on("^lsp%-docs%-method%-(.+)$", function(_v, _id, path) state.selectedMethod = path:gsub("%.", "/") end)
app:on("lsp-docs-search", function(v) state.searchQuery = v end)
app:on("^lsp%-docs%-kind%-(.+)$", function(_v, _id, k) state.kindFilter = k end)
```
## Notes
- Callback ids can't contain `/`, so the widget substitutes `.` for
`/` in method paths on emit. The receiving handler must undo it.
- The fetch logic (calling `lsp.*`) lives in the parent app, not in
this widget — that keeps it cheap to test without an active LSP.
- The split sizes default to 20% / 30% / 50%. Override via `opts.sizes`
for inspector layouts that need different widths.
# severitySummary
Colored badge row of LSP diagnostic severity counts. Input shape
matches `lsp.summary()` / `lsp.checkAll()` — `{ errors, warnings,
info, hints }`. Severities with zero count are hidden by default; pass
`opts.showZero = true` to keep them in the row. When all counts are
zero the widget falls back to a neutral "0 diagnostics" chip so the
header doesn't collapse.
## Exports
- `severitySummary(counts: Counts?, opts: Opts?) -> any` — build the hbox of severity chips. Returned directly by the module.
Types:
- `Counts = { errors?: number, warnings?: number, info?: number, hints?: number }`
- `Opts = { id?: string, showZero?: boolean, tooltips?: boolean, gap?: number, padding?: { number } }`
## Usage
```luau
local severitySummary = require("@builtin::modules.zui.widget.lsp.severitySummary")
local widget = severitySummary(lsp.summary(), { tooltips = true })
```
## Notes
- Chip colors flow from `Z.theme.danger / warn / info / success` so the
palette automatically tracks theme overrides.
- The "0 diagnostics" fallback prevents the parent layout from jumping
when diagnostics first appear — important for fixed-height headers.
- Pure presentational — no LSP calls, no side effects.
# strictModeToggle
Three-button radio for the LSP strict gate. Pass the current mode
(`"off" | "soft" | "strict"` — exactly what `lsp.getStrictMode()`
returns) and a callback prefix; emits `<onChange>-<mode>` when a
button is clicked. The widget is presentational only — the owning app
calls `lsp.setStrictMode()` from its handler and mirrors the change
back into its render state.
## Exports
- `strictModeToggle(currentMode: string?, onChange: string?, opts: Opts?) -> any` — build the hbox of mode buttons. Returned directly by the module.
Types:
- `Opts = { id?: string, label?: any, padding?: { number }, containerPadding?: { number } }`
## Usage
```luau
local strictToggle = require("@builtin::modules.zui.widget.lsp.strictModeToggle")
local widget = strictToggle(lsp.getStrictMode(), "lsp-strict")
app:on("^lsp%-strict%-(.+)$", function(_v, _id, mode)
lsp.setStrictMode(mode)
state.strictMode = mode
end)
```
## Notes
- Decouples the visual from the side effect — matches the rest of the
zui composite-widget convention so screens stay testable without an
active LSP.
- Pass `opts.label = false` to drop the leading label entirely (e.g.
for compact inspectors that already provide context).
- Tooltips on each button explain exactly which classes of error are
gated at that level.
# dataView.selectionModel
Pure selection-interaction algebra for `Z.dataView`. Maps a row click plus
modifier state to a new selected-index set and anchor, following the standard
desktop model: plain click replaces, ctrl toggles, shift selects the contiguous
range from the anchor.
## Exports
- `M.resolveClick(index, mods, current, anchor) -> { selected: { number }, anchor: number }`
— 1-based indices; `mods` is `{ ctrl?, shift? }`; `current` is the current
selected-index array; `anchor` is the current anchor or nil.
## Notes
- Pure functions, no engine calls — unit-tested in the `editor_dataview` suite.
- Callers translate the returned indices into selection-service refs.
# dataView.view
Pure view computation for `Z.dataView` — filter then sort an item array into an
ordered list of 1-based indices.
## Exports
- `M.computeView(items, spec) -> { number }` — `spec` is
`{ filter?, sortKey?, sortDir?, textOf, valueOf }`. Filter is a case-insensitive
substring over `textOf(item)`; sort compares `valueOf(item, sortKey)`
numerically or case-insensitively, ascending or descending, with a stable
tiebreak on the original index.
## Notes
- Pure functions, no engine calls — unit-tested in the `editor_dataview` suite.
# dataView.virtual
Pure virtualization window math for `Z.dataView` — given a scroll position and
geometry, compute the inclusive range of row indices that are visible.
## Exports
- `M.windowFor(scrollTop, viewportH, rowH, count, overscan) -> { first, last }`
— 1-based inclusive; `last < first` when the dataset is empty. `overscan`
extends the window above and below the visible band.
## Notes
- Pure functions, no engine calls — unit-tested in the `editor_dataview` suite.
- Backs the row-window rendering; the same math would drive a future
construction-virtualization provider.
# fromFlat
Build the recursive node table `Z.tree` expects from a flat array of
items where each item carries a parent link. Useful for ECS entity
snapshots (each entity has `parentId`) and any other parent-pointer
dataset.
## Exports
- `fromFlat(items, opts?) -> (roots, nodeById)` — returns the array of root nodes and a stringified-id → node map. Module returns the builder function directly.
Types:
- `FromFlatOpts = { idKey?, parentKey?, labelFn?, iconFn?, actionsFn?, expanded?, defaultExpanded? }`
- `TreeNode = { key, label, children?, payload, icon?, actions?, expanded? }`
## Usage
```luau
local fromFlat = require("@builtin::modules.zui.widget.tree.fromFlat")
local roots, byId = fromFlat(entities, {
idKey = "id",
parentKey = "parentId",
labelFn = function(e) return e.name or "#" .. e.id end,
iconFn = function(e) return e.children and "▣" or "·" end,
expanded = state.expanded,
defaultExpanded = false,
})
```
## Notes
- Items missing the id field are silently skipped — we cannot place
them in the tree without a key, and crashing on bad data would kill
the inspector.
- Items whose `parentKey` value isn't found in the input array are
treated as roots, matching typical ECS behaviour where a filtered
list may contain children whose parents were filtered out.
- The `expanded` map is consulted by stringified key first, then by
the raw id; absent keys fall back to `defaultExpanded`.
# breadcrumb
Breadcrumb path widget — renders `/zero/runtime/scenes/...` as a row
of clickable segments. Each segment emits a callback id carrying the
absolute path up to and including that segment, so a single router
rule reaches every navigation target. Stateless — caller passes the
current absolute path and a callback prefix; receives a hbox.
## Exports
- `breadcrumb(path: string, opts: BreadcrumbOpts?) -> WidgetNode` — build a hbox of breadcrumb segments. Home (`⌂`) segment always renders first; each `/`-delimited segment renders as a button whose callback id is `<onNavigate>-<absolute-path>`.
Types:
- `BreadcrumbOpts = { id: string?, onNavigate: string? }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
local crumb = Z.fs.breadcrumb("/zero/runtime/scenes/", {
onNavigate = "files-nav",
})
app:on("^files%-nav%-(.+)$", function(_v, _id, path)
state.currentPath = path
end)
```
## Notes
- Stateless. No state ownership, no engine calls beyond the widget
primitives.
- A trailing slash on the input path is stripped before splitting
unless the whole input is `/`.
- The home crumb's callback id is `<onNavigate>-/` so a single router
rule (`^files%-nav%-(.+)$`) covers root too.
# controller
The state half of the generic Explorer. The `Z.fs.*` builders are stateless (data in, widgets out); this controller owns the VFS-backed navigation state and the callback routing that drives them, so both the Files panel and the save/load dialog reuse one model and one router.
It operates on a caller-owned `slot` table (so two Explorers — e.g. the panel and a dialog — keep independent state) and a caller-chosen callback `prefix` (so their widgets' callbacks don't collide). The controller caches directory listings, tracks the current directory, the expanded folder set, the selected path, and a file preview.
## API
- `M.init(slot, opts?) -> slot` — idempotently seed a controller state slot. `opts.root` defaults to `/zero/`.
- `M.model(slot)` — build the render model the `Z.fs.explorer` / `Z.fs.dialog` builders consume: `{ root, currentDir, breadcrumbPath, treeRoots, listEntries, selectedPath, expanded, preview }`.
- `M.handle(slot, prefix, callbackId, data?) -> boolean` — route an Explorer callback against this slot; returns true when handled.
- `M.invalidate(slot, pathPrefix?)` — drop cached listings under `pathPrefix` (or all when nil) so fresh writes show.
- `M.readPreview(path) -> (text|nil, note, size)` — read a file preview; refuses binary, truncates large text.
- `M.confirmTarget(slot, mode, filename) -> path|nil` — resolve the absolute target a dialog confirm should act on. In `"save"` mode it's `currentDir .. filename` (nil if filename is empty); in `"open"` mode it's the selected file (nil if nothing or a folder is selected).
## Callback grammar
All callbacks carry the absolute path verbatim:
- `<prefix>-nav-<path>` — breadcrumb segment
- `<prefix>-tree-select-<path>` — tree row
- `<prefix>-tree-toggle-<path>` — tree chevron
- `<prefix>-list-<d|f>-<path>` — details-list row (`d` = dir, `f` = file)
- `<prefix>-up` — parent directory
- `<prefix>-refresh` — drop cache for the visible subtree
## Usage
```luau
local fsc = require("modules.zui.widget.fs.controller")
fsc.init(state.files, { root = "/zero/" })
local model = fsc.model(state.files)
local widget = Z.fs.explorer(model, { prefix = "files" })
-- in onCallback:
if fsc.handle(state.files, "files", callbackId, data) then end
```
# detailsList
The right pane of the two-pane Explorer: a details list of one directory's entries with an icon, name, and kind column. Folders sort first (warm accent), then files. Stateless — the caller passes the entries (each already carrying its absolute `path`) and a callback prefix, and receives a scrollable column of clickable rows.
A row click emits `<onActivate>-<d|f>-<path>` — the `d`/`f` marker tells the handler folder-vs-file without a second lookup, and the absolute path is carried verbatim. Each row shows a phosphor icon, a clickable name button, and a kind column derived from the file extension.
The module returns the builder function directly.
## Builder
`build(entries?, opts?) -> widget node`.
- `entries` — array of `{ name, isDirectory?, path }` (path = the entry's absolute path).
- `opts` (`DetailsListOpts`) — `id`, `onActivate` (callback prefix; rows emit `<onActivate>-<d|f>-<path>`), `selectedPath` (the highlighted row), `maxHeight` (caps the scroll height; otherwise the pane fills available height via flex), `showHeader` (the Name · Kind header row, default on).
Returns a scroll-wrapped column with an optional header row and one row per entry; an empty list renders `(empty)`.
## Usage
```luau
Z.fs.detailsList(entries, {
onActivate = "files-list",
selectedPath = state.selectedPath,
})
```
# dialog
A modal save / load file picker: the same two-pane Explorer (`Z.fs.explorer`) inside a `Z.modal`, plus a filename field (save mode) and Open/Save + Cancel buttons. The panel and the dialog share one Explorer and one controller, so navigating feels identical in both.
Stateless builder: pass the controller model and the dialog options; the host owns `open`, the filename string, and the confirm/cancel wiring. The module returns the builder function directly.
## Builder
`build(model, opts?) -> widget node` (a `Z.modal` tree; renders nothing when `open` is false).
- `model` — the `Z.fs.controller.model(slot)` result for the dialog's own controller slot.
- `opts` (`DialogOpts`) — `prefix` (callback prefix, default `"fsdlg"`), `open` (modal visibility, host-owned), `title`, `mode` (`"save"` for a filename field, `"open"` default), `filename` (save-mode field value), `confirmLabel` (defaults to "Save"/"Open" by mode), `width` (default 640), `height` (default 460), `treeFrac`, `id`.
## Callbacks
Beyond the Explorer's `<prefix>-*` navigation:
- `<prefix>-filename` — filename field changed (value in the event payload)
- `<prefix>-confirm` — Open/Save pressed
- `<prefix>-cancel` — Cancel pressed or the modal dismissed
## Usage
```luau
-- host state: state.dlg = {} (controller slot), open, filename
Z.fs.dialog(Z.fs.controller.model(state.dlg), {
prefix = "savedlg", open = state.open, mode = "save",
title = "Save Layout", filename = state.filename,
confirmLabel = "Save",
})
-- host onCallback:
-- Z.fs.controller.handle(state.dlg, "savedlg", cb, data) -- navigation
-- "savedlg-filename" → state.filename = Z.eventValue(data)
-- "savedlg-confirm" → path = Z.fs.controller.confirmTarget(state.dlg, "save", state.filename)
-- "savedlg-cancel" → state.open = false
```
# explorer
A two-pane filesystem Explorer composite: a breadcrumb plus up/refresh actions on top, a folder tree on the left, and a details list (icon · name · kind) of the current directory on the right. Stateless — pass the model from `Z.fs.controller.model(slot)` and a callback `prefix`; the controller's `handle(slot, prefix, ...)` routes the callbacks this emits.
The same composite is the body of `Z.fs.dialog` (the modal save/load picker), so the panel and the dialog look and behave identically. The explorer fills its parent's height via flex, so the host panel just needs to give it room.
The module returns the builder function directly.
## Builder
`build(model, opts?) -> widget node`.
- `model` — the `Z.fs.controller.model(slot)` result: `{ root, currentDir, breadcrumbPath, treeRoots, listEntries, selectedPath, expanded }`.
- `opts` (`ExplorerOpts`) — `prefix` (callback prefix, default `"fs"`), `treeFrac` (tree pane fraction of width, 0..1, default 0.4), `id`.
Returns a vbox of the action row (up + refresh + breadcrumb) and a resizable horizontal split of the tree and details-list panes.
## Usage
```luau
local model = Z.fs.controller.model(state.files)
Z.fs.explorer(model, { prefix = "files", treeFrac = 0.4 })
```
# fileList
Flat row list of files in a directory. Folders first (orange), then
files (selected file highlighted). Stateless — caller passes the
entries array `[{name, isDirectory}]` and a callback prefix; receives
a scrollable vbox of clickable rows. Each click emits
`<onSelect>-<index>` where index is 1-based into the *original*
entries array, so the caller doesn't need a sorted-vs-original map.
## Exports
- `fileList(entries: { FileEntry }?, opts: FileListOpts?) -> WidgetNode` — build a scrollable vbox of rows. Folders style differently from files; the selected file row is highlighted.
Types:
- `FileEntry = { name: string, isDirectory: boolean? }`
- `FileListOpts = { id: string?, onSelect: string?, selectedKey: string?, sort: string?, maxHeight: number? }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
return Z.fs.fileList(state.entries, {
onSelect = "files-row",
selectedKey = state.selectedName,
sort = "dirs_first", -- or "alpha"
})
app:on("^files%-row%-(%d+)$", function(_v, _id, idx)
state.selectedName = state.entries[tonumber(idx)].name
end)
```
## Notes
- Stateless. The caller owns the entries array and the selection key.
- The callback id carries an index (1-based) rather than the file
name, so unusual characters in paths can't break the router.
- `sort` defaults to `"dirs_first"` (folders alphabetically, then files
alphabetically). `"alpha"` ignores type. Any other value preserves
the caller's order.
- An empty entries array produces a single `(empty directory)` label.
# preview
File preview pane — header, optional note line ("Size: 12345 bytes" /
"Truncated: ..."), separator, and the body. Body is monospace text
when `text` is non-nil, or a "(no preview)" line when nil. Stateless:
caller decides what `text`/`note` should be by reading the file (e.g.
via `vfs.read`).
## Exports
- `preview(data: PreviewData?, opts: PreviewOpts?) -> WidgetNode` — build the preview pane. When all three `data` fields are nil, renders a "Select a file to preview." placeholder.
Types:
- `PreviewData = { name: string?, text: string?, note: string? }`
- `PreviewOpts = { id: string?, scrollHeight: number?, onCopyPath: string? }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
return Z.fs.preview({
name = state.name,
text = state.text,
note = "Size: " .. tostring(state.size) .. " bytes",
}, {
id = "files-preview",
scrollHeight = 480,
onCopyPath = "files-copy",
})
```
## Notes
- Stateless. Caller owns the file read and decides what `text` and
`note` should be (truncation messaging, byte counts, hexdumps, etc.
are all the caller's job).
- `scrollHeight` defaults to 480; only applies when `data.text` is
non-nil (the body is wrapped in a `scroll` container).
- The copy-path button is only rendered when `opts.onCopyPath` is set;
the click emits that callback id verbatim so the caller routes it.
# tree
Folder tree composite. Wraps `Z.tree` with folder-friendly defaults:
folder icon for directories, file icon for leaves, callback ids that
carry the absolute path. Stateless — caller owns the recursive node
table. Use this when you want a single deep tree of folders + files;
for the classic two-pane ("folder tree | file list") layout, use this
for the left pane and `Z.fs.fileList` for the right.
## Exports
- `tree(roots: { FsNode }?, opts: FsTreeOpts?) -> WidgetNode` — build a recursive folder tree. Callback ids: `<onSelect>-<path>` (label click), `<onToggle>-<path>` (chevron click).
Types:
- `FsNode = { name: string, path: string?, isDirectory: boolean?, children: { FsNode }?, expanded: boolean? }`
- `FsTreeOpts = { id: string?, onSelect: string?, onToggle: string?, selectedKey: string?, expanded: { [string]: boolean }?, basePath: string?, indentPx: number?, gap: number? }`
- `WidgetNode = { [string]: any }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
return Z.fs.tree(state.roots, {
onSelect = "files-tree-select",
onToggle = "files-tree-toggle",
selectedKey = state.selectedPath,
expanded = state.expandedSet,
})
app:on("^files%-tree%-select%-(.+)$", function(_v, _id, path)
state.selectedPath = path
end)
app:on("^files%-tree%-toggle%-(.+)$", function(_v, _id, path)
state.expandedSet[path] = not state.expandedSet[path]
end)
```
## Notes
- Stateless. Caller owns selection, expansion, and on-demand listing
(the `onToggle` handler is where you'd `vfs.list` and refill
`children` for the next render).
- Folders without listed children (`children == nil`) still get a
chevron via `expandable = true`. This is the lazy-load shape.
- `basePath` defaults to `/`. Used when an `FsNode` has no `path` —
the recursive walk builds the absolute path on the fly.
# shell
Pre-built shells for common app shapes. Each shell is a function taking
a single props table; missing fields fall back to sensible defaults.
See `docs/specs/ui-v3-architecture.md` Layer 5 for the canonical
signatures.
## Exports
- `Shell.docked` — toolbar + body + status layout. See `docked.module/`.
- `Shell.canvas` — node-graph editor with bezier connections. See `canvas.module/`.
- `Shell.inspector` — form-with-fields-and-actions pane. See `inspector.module/`.
- `Shell.tabApp` — visibility-driven tabbed app with per-tab refresh scheduler. See `tabApp.module/`.
## Usage
```luau
local Shell = require("@builtin::modules.zui.shell")
Shell.docked { top = ..., center = ..., bottom = ... }
Shell.tabApp { name = "system_tools", tabs = ..., tabOrder = ... }
```
## Notes
- This module is a pure aggregator: it does nothing but re-export the
four shell submodules. Each shell owns its own behaviour, defaults,
and contract; consult the respective submodule for the prop shapes.
- Missing slots in any shell are skipped cleanly — there are no empty
containers, no wasted space.
# dynamic
`Z.dynamic(list, fn)` — one screen per list item. The `Z.app` mount/tick
loop recognises the wrapper and registers `<base>-<item.id>` screens;
on every tick it diffs against the previously-registered ids and
unregisters screens whose items disappeared.
## Exports
- `Dynamic.create(list: { any }, fn: (any, number) -> any) -> Wrapper` — build a dynamic-list bucket consumed by `Z.app`.
- `Dynamic.is(value: any) -> boolean` — identify a `Z.dynamic(...)` wrapper.
- `Dynamic.itemId(item: any, index: number) -> string` — derive a stable per-item id (falls back to the array index).
Types:
- `Wrapper = { list, fn, ... }` — opaque wrapper carrying the `__zuiDynamic = true` tag.
## Usage
```luau
local Dynamic = require("@builtin::modules.zui.dynamic")
local Z = require("@builtin::modules.zui")
return Z.app("inventory", function(state)
return {
items = Z.dynamic(state.items, function(item, index)
return Z.lbl(item.label)
end),
}
end)
```
## Notes
- The wrapper carries an internal `__zuiDynamic = true` tag — call
`Dynamic.is(v)` instead of inspecting keys directly.
- Each item should expose a stable `.id` field; without one, the screen
name uses the array index, which makes ordering changes feel like
add/remove events.
- The list is read by reference on every tick — mutating it between
ticks is the normal way to drive add/remove/reorder.
- Unregistration is automatic: when an item disappears from the list,
its per-item screen is unregistered on the next tick.
# utils
Formatting and dispatch helpers shared across zui layers. Anything
domain-agnostic and useful both inside zui and to library consumers
goes here — numeric formatting (WASM-safe, no `string.format` dependency),
stable hierarchical widget id construction, callback payload unwrap, and
small list comprehensions.
## Exports
- `M.round(v: number?, decimals: number?) -> number` — round to `decimals` places (default 2). `nil` → 0.
- `M.fmt(v: number?, decimals: number?) -> string` — `tostring(round(v, decimals))`.
- `M.fmtVec3(vec: Vec3Array?, decimals: number?) -> string` — format `{x, y, z}` as `"(x, y, z)"`.
- `M.id(parent: any, child: any) -> string` — stable widget id (`"parent/child"`); empty parent drops the prefix.
- `M.eventValue(data: EventEnvelope?) -> any` — unwrap `data.value` from v2 event envelopes; pass through raw payloads (`nil` returns `nil`).
- `M.map(items: { any }?, fn: (any, number) -> any?) -> { any }` — map and drop `nil`s.
- `M.when(cond: any, widget: any) -> any` — `widget` if `cond`, else `nil`. Pairs with `compact`.
- `M.compact(widgets: { any }?) -> { any }` — drop `nil` entries.
Types:
- `Vec3Array = { number }` — array form, `{ x, y, z }`.
- `EventEnvelope = { value: any? } | any`
## Usage
```luau
local Utils = require("@builtin::modules.zui.utils")
Utils.round(1.2345) -- 1.23
Utils.fmt(1.2345, 1) -- "1.2"
Utils.fmtVec3({1, 2, 3}) -- "(1.00, 2.00, 3.00)"
Utils.id("toolbar", "save") -- "toolbar/save"
local rows = Utils.map(items, function(r) return Z.lbl(r.name) end)
local kids = Utils.compact{ Z.lbl("a"), Utils.when(showFoo, Z.lbl("foo")) }
```
## Notes
- Pure module — no engine calls, no module state. Safe to call from any
context.
- `round` / `fmt` / `fmtVec3` are WASM-safe; they avoid `string.format`
for the rounding step and use repeated multiplication for `10^n`.
- `eventValue` is the canonical onCallback unwrap — preferred over manual
`data.value` access because it transparently handles the older raw-payload
flow too.
- `map` / `compact` / `when` are the canonical list comprehensions; demos
rely on the `nil` filtering for conditional widgets.
# app
Layer 3 lifecycle wrapper for `Z.app`. Collapses the
register-or-update dance, owns multiple named screens, ticks reactive
state, and routes callbacks via an embedded `Router`. Per
`docs/specs/ui-v3-architecture.md` Layer 3. The instance is a callable
`{ buildFn }` plus a `State` reactive store and a `Router` callback
dispatcher; mount → tick → unmount drives `ui.registerScreen` /
`ui.updateScreen` / `ui.unregisterScreen` under the hood.
## Exports
- `App.create(name: string, builderFn: BuilderFn) -> AppInstance` — construct an App.
- `App.current() -> AppInstance?` — the App whose builder is currently rendering (used by widget builders that auto-stash state).
Per-instance methods (on `AppInstance`):
- `:mount(ctx: any) -> AppInstance` — initial register; idempotent across re-mounts.
- `:tick(dt: number?)` — re-render dirty screens from the host's `update(dt)`.
- `:unmount()` — unregister every owned screen and drop the token claims.
- `:markDirty()` / `:isDirty() -> boolean` — dirty bit control.
- `:on(idOrPattern, handler) -> AppInstance` — router subscription (chainable).
- `:cb(id, handler) -> id` — router callback registration; returns the id.
- `:dispatch(callbackId, data?)` — router dispatch.
- `:set(key, value) -> AppInstance` / `:update(updates) -> AppInstance` — state shortcuts.
- `:_screensForTest()` / `:_routerForTest()` — test introspection.
- Internals (`_nextAnonId`, `_runBuilder`, `_register`, `_renderOne`, `_isRegistered`, `_isFirstRegister`, `_forgetScreen`, `_showAll`) are exposed for use by `Z.app`'s helpers but should not be called externally.
Types:
- `AppInstance` — opaque App table carrying `name`, `state`, the embedded router, and registered-screen bookkeeping.
- `BuilderFn = (state: any, ctx: any) -> { [screenName] = buildFn | dynamic }`
- `RenderOpts = { layer: number?, tags: { string }? }`
## Usage
```luau
local Z = require("@builtin::modules.zui")
local app = Z.app("inventory", function(state, ctx)
state:default("count", 0)
return {
main = function()
return Z.btn("count=" .. state:get("count"), "inc")
end,
}
end)
app:on("inc", function() app:set("count", app.state:get("count") + 1) end)
app:mount(self)
-- in update(dt):
app:tick(dt)
-- on destroy:
app:unmount()
```
## Notes
- A per-process token-based stale-screen guard keeps a second App with
the same screen name from doubling-up `ui.updateScreen` pushes. The
newest mounted App wins; older instances skip their tick silently.
- Closure-as-callback widgets (`Z.btn("Save", fn)`) get stable
per-screen ids via `_nextAnonId` so re-ticks update the existing
router entry instead of accumulating handlers.
- `Z.dynamic(list, fn)` values in the builder's return table are
exploded into per-item screens named `<base>-<item.id>`; vanished
items are unregistered automatically on the next tick.
- `{ build, layer, tags }` is the supported sugar for screens that need
`ui.registerScreen`'s optional layer or `Z.tags.set` entries. Tags
are written immediately after register so the editor toggles and
`Z.tags.findByTag` see them on the same frame.
- A build that returns `nil` means "this screen has nothing to draw this
frame": the App hides the screen and shows it again on the first tick
the build produces a tree. That is the whole of the App's claim on
visibility — a screen hidden by anyone else (`ui.hideScreen`,
`Z.screens`, the editor's F1 toggle via `Z.tags.hideByTag("editor")`)
stays hidden while the App keeps pushing tree updates on its refresh
clock, and becomes visible again when that caller shows it.
- Errors thrown by a builder propagate through `pcall` and re-raise
with `error(..., 0)` so the screen name is preserved in the
traceback.
# highlight
Syntax-highlighting dispatch + per-language modules. Each language
module exports `(text: string) -> { Segment }` where
`Segment = { text, color, monospace?, italic? }`. `Z.codeEditor` calls
into `dispatch` at update time and passes the resulting segments to
TextInput, which builds the egui LayoutJob in pure Luau. Library authors
can ship their own `@author/zui_highlight_<lang>` modules and slot them
into `Z.codeEditor` via the same `language` opt.
## Exports
- `M.dispatch(language: string, text: string) -> { Segment }` — pick a per-language highlighter (with `yml` / `md` aliases) and tokenize; unknown languages fall through to a single plain segment.
- `M.lua: Highlighter` — Lua highlighter.
- `M.json: Highlighter` — JSON highlighter.
- `M.yaml: Highlighter` — YAML highlighter.
- `M.wgsl: Highlighter` — WGSL highlighter.
- `M.markdown: Highlighter` — Markdown highlighter.
Types:
- `Segment = { text: string, color: string, monospace: boolean?, italic: boolean? }`
- `Highlighter = (string) -> { Segment }`
## Usage
```luau
local Highlight = require("@builtin::modules.zui.highlight")
local segs = Highlight.dispatch("lua", source)
-- segs = { { text = "local", color = "#...", monospace = true }, ... }
-- Or call a specific language directly:
local segs2 = Highlight.json(jsonText)
```
## Notes
- Per-language colors come from the active theme's `code.*` tokens
(`ui.getToken("code.keyword")` etc.). Switching themes
(`ui.setTheme("light")`) automatically re-colors on the next call.
- A single-slot LRU cache keyed by `(language, text, theme)` makes
same-text re-emits free (focus-loss redraws, no-op editor pushes).
Cache returns a shared array reference — callers must not mutate it.
- Aliases: `"yml"` → `yaml`, `"md"` → `markdown`. Unknown languages
return a single default-colored monospace segment so user input never
crashes the dispatcher.
## Roles the Lua highlighter reads
`code.keyword`, `code.string`, `code.number`, `code.comment`, `code.bool`
(`true` / `false` / `nil`), `code.attribute` (`--!` directives),
`code.type` (a name after `:`, `->`, `::` or `type`), `code.function` (a name
a call is made through, or one `function` declares), `code.property` (a
member reached by `.` that is not called), `code.builtin` (the standard
library's globals), and `code.default` for the rest.
# theme
Theme tokens for zui (Layer 2). Reads through `ui.getToken(name)` first
so a loaded engine theme propagates automatically; falls back to
in-module defaults that cover every named color used by the demos. Also
owns the Luau-side `$variable` cascade — `register` / `load` resolve
references end-to-end (cycle-detected) and push flat values to
`ui.registerTheme`.
## Exports
- `M.with(overrides: TokenMap?) -> ThemeView` — read-only view with overlays on top of engine/defaults.
- `M.defaults() -> TokenMap` — raw default token map (same reference each call).
- `M.tokenNames() -> { string }` — sorted list of shipped token names.
- `M.resolve(theme: any) -> (ResolvedTheme?, string?)` — flatten a theme table (`$var` → literal). Returns `(nil, errMsg)` on failure.
- `M.resolveTokens(theme) -> (TokenMap?, string?)` — re-export from cascade.
- `M.resolveStyles(theme, tokens) -> (StyleMap?, string?)` — re-export from cascade.
- `M.register(name: string, theme: any) -> (boolean, string?)` — resolve and push to `ui.registerTheme`.
- `M.load(name: string) -> (boolean, string?)` — require `@builtin::themes.<name>` and register it.
- `M.activate(name: string) -> boolean` — thin wrapper over `ui.setTheme(name)`.
- `M.default: ThemeView` — module-level token view with no overrides.
Types:
- `TokenMap = { [string]: any }`
- `StyleMap = { [string]: { [string]: any } }`
- `Theme = { name: string?, tokens: TokenMap?, styles: StyleMap? }`
- `ResolvedTheme = { name: string, tokens: TokenMap, styles: StyleMap }`
- `ThemeView` — read-only metatable proxy; writes throw.
## Usage
```luau
local Theme = require("@builtin::modules.zui.theme")
Theme.load("dark") -- require + register the built-in dark theme
Theme.activate("dark") -- ui.setTheme("dark")
local view = Theme.with({ accent = "#ff0" })
print(view.accent) -- "#ff0"
print(view.bg) -- engine token or default
```
## Notes
- `register` overrides `theme.name` with the caller-supplied name so
`listThemes()` / `setTheme(name)` find it under the requested key
(mirrors the pre-Phase-4 #942 fix).
- The cascade is cycle-detected — broken inputs surface as structured
`(false, errMsg)` returns instead of silently producing garbage colors.
- `M.default` is built at module-load time, after `M` is fully populated,
so its function-fallback `__index` resolves correctly.
- `ThemeView` writes raise — use `Z.themeWith({...})` to get an overlay
view rather than mutating the existing one.
# state
Reactive state container for `Z.app`. Backs the Layer 3 example in
`docs/specs/ui-v3-architecture.md` — `state:default(...)`,
`state.nodes`, `state:set("size", n)`, etc. Writes via the data-store
path (`state.foo = bar`) or the method path (`state:set("foo", bar)`)
both auto-mark the owning App dirty.
## Exports
- `State.create(initial: { [string]: any }?, app: App?) -> StateInstance` — make a new container.
- Instance methods (also reachable via reactive `state.foo = bar` writes):
- `state:default(initial)` — write only keys not already present; idempotent. Does NOT mark dirty.
- `state:get(key)` — typed read of a data-store key.
- `state:set(key, value)` — write one key; marks dirty on change.
- `state:update(updates)` — bulk write; single dirty-mark per call.
- `state:all()` — return the raw data-store table.
Types:
- `App = { markDirty: ((App) -> ())? }` — minimum owning-App contract.
- `StateInstance` — the metatable-bound container; supports `state.foo` field access in addition to the methods.
## Usage
```luau
local State = require("@builtin::modules.zui.state")
local s = State.create({ count = 0 }, app)
s:default({ size = 10 }) -- idempotent, no dirty mark
s.nodes = { ... } -- reactive __newindex, marks dirty
s:set("count", 5) -- method path, marks dirty
```
## Notes
- The methods table is consulted by `__index` BEFORE the data store, so
`state:default` resolves to the method even if a `default` data key
exists.
- `:default` is a setup-time merge and intentionally does NOT mark the
App dirty — it's safe to call inside an `App.create` builder.
- `:update` collapses multiple key writes into a single dirty-mark; use
it in hot loops where the per-key `__newindex` path would mark dirty
on every assignment.
# tags
Luau-owned screen-tag registry for zui. Tags are pure derived metadata
over the `ui.hideScreen` / `ui.showScreen` / `ui.listScreens`
capabilities; the source of truth lives here, not in the engine. Typical
use is the editor F1 toggle — every editor-owned UI screen is tagged
`"editor"` at register time and the editor's default scene flips them all
in one call via `hideByTag` / `showByTag`.
## Exports
- `M.set(name: string, tags: { string }?)` — replace `name`'s tag set with the array. Nil/empty clears.
- `M.add(name: string, tag: string)` — add a single tag, creating the entry if absent.
- `M.remove(name: string, tag: string)` — drop a tag; drops the registry entry when the set becomes empty.
- `M.clear(name: string)` — drop every tag for `name` (idempotent).
- `M.get(name: string) -> { string }` — sorted array of tags for `name` (empty when no entry).
- `M.findByTag(tag: string) -> { string }` — sorted screens currently registered AND visible-via-`ui.listScreens` carrying `tag`.
- `M.hideByTag(tag: string)` — hide every currently-visible screen tagged with `tag`.
- `M.showByTag(tag: string)` — show every currently-hidden screen tagged with `tag`.
- `M._registry: Registry` — internal `{ [name] = { [tag] = true } }` map. Inspectable but treat as private.
Types:
- `TagSet = { [string]: boolean }`
- `Registry = { [string]: TagSet }`
- `ScreenInfo = { name: string, visible: boolean }`
- `LiveScreens = { [string]: ScreenInfo }`
## Usage
```luau
local Tags = require("@builtin::modules.zui.tags")
Tags.set("inspector", { "editor" })
Tags.add("console", "editor")
Tags.hideByTag("editor") -- hides every visible "editor"-tagged screen
Tags.showByTag("editor") -- shows every hidden "editor"-tagged screen
local screens = Tags.findByTag("editor") -- sorted live matches
```
## Notes
- Registry is not auto-pruned. `ui.listScreens()` is one frame behind, so
same-frame register-then-tag flows must keep their entries intact;
bulk ops filter via the live screen snapshot.
- Memory is bounded by total session screen count.
- `set` with `nil` / `{}` is the only way to clear an entry through `set`;
use `clear(name)` for the explicit variant.
- All bulk ops are silent on missing FFI bindings (`ui.hideScreen`,
`ui.showScreen`, `ui.listScreens`) — safe to call before the UI layer
is wired.
# dataSource
TTL + gated cached data fetcher. Used by tools that pull live data from
the engine at a slower cadence than the UI rebuilds. A closed gate
returns the last cached value WITHOUT calling the fetch function; within
TTL, the cached value is returned directly. Errors from the fetcher are
swallowed by default (last value preserved) and surfaced through the
optional `onError` hook.
The module table itself is callable as a shorthand for `.create`.
## Exports
- `M.create(fetchFn: () -> any, opts: Opts?) -> Source` — build a new source. Also reachable as `M(fetchFn, opts)`.
- `M.invalidate(key: string)` — force a re-fetch on the next read for a registered key.
- `M.reset()` — wipe the registry; release retained values.
- `M._seedForTest(key: string, value: any) -> Source?` — test hook: install a value without invoking the fetcher.
- `M._peekForTest(key: string) -> Source?` — test hook: look up a source by key.
Per-instance methods (on `Source`):
- `source:read() -> value` — read with TTL + gate semantics.
- `source()` — callable shorthand, equivalent to `source:read()`.
- `source:invalidate()` — clear cached value and timestamp.
- `source.version: number` — monotonic counter bumped on every successful fetch.
Types:
- `Opts = { ttl: number?, gate: (() -> boolean)?, key: string?, onError: ((any) -> ())? }`
- `Source` — instance carrying `fetchFn`, `ttl`, `gate`, `onError`, `key`, `version`, and the internal `_value` / `_t` cache slots.
## Usage
```luau
local DataSource = require("@builtin::modules.zui.dataSource")
local entities = DataSource(function()
return wld.list()
end, { ttl = 0.2, key = "entities:list" })
local list = entities() -- callable shorthand
local same = entities:read() -- explicit form
DataSource.invalidate("entities:list") -- force re-fetch
```
## Notes
- TTL `0` means "always re-fetch"; the cache slot still holds the last
value so a closed gate or a fetch error returns it.
- A closed gate (`gate()` returns false) skips the fetch entirely and
returns the cached value as-is — useful for visibility gating.
- Fetcher errors are swallowed (cached value preserved) and routed
through `onError(err)` when set.
- Auto-allocated keys are namespaced under `"zui:dataSource:<n>"`.
Explicit keys are preferred so `invalidate` and the test hooks have a
stable handle.
# screens
Derived screen-lifecycle helpers on top of the `ui.*` capability
surface. Every operation is computed from `ui.listScreens()` +
`ui.hideScreen` / `ui.showScreen`; the module owns no engine state of
its own. Deliberately does NOT wrap `ui.registerScreen` /
`ui.updateScreen` / `ui.unregisterScreen` — those are capabilities
the engine needs to manage directly.
## Exports
- `M.exists(name: string) -> boolean` — does the screen exist in the current snapshot?
- `M.visible(name: string) -> boolean` — does it exist AND is it currently shown?
- `M.toggle(name: string)` — flip a visible screen hidden, or a hidden screen visible. Unknown screens are no-ops.
- `M.list() -> { ScreenEntry }` — pass-through to `ui.listScreens()`.
- `M.byName() -> { [string]: ScreenEntry }` — same data, map shape.
- `M.hideAll()` — hide every currently-visible screen.
Types:
- `ScreenEntry = { name: string, visible: boolean, layer: number?, hasRoot: boolean? }`
## Usage
```luau
local Screens = require("@builtin::modules.zui.screens")
if Screens.visible("hud") then Screens.toggle("hud") end
```
## Notes
- The screen snapshot is one frame behind on register/update commands —
a freshly registered screen returns `false` from `exists` until the
next frame.
- `toggle` is a state-driven flip, not a flag write — it reads the
current state before deciding to show or hide.
- All operations are safe when the `ui` global is unavailable; they
no-op or return empty values.
# router
Layer 4 callback router for `Z.app`. Pattern-matched dispatch with
auto-`data.value` extraction and a one-shot warning on unhandled ids so
authors find stale callbacks during iteration. Each router instance
owns its own handler map, pattern list, and warning gate.
## Exports
- `Router.create() -> RouterInstance` — make a fresh router.
- `Router:on(idOrPattern: string, handler: Handler) -> RouterInstance` — register a handler; patterns are auto-detected.
- `Router:cb(id: string, handler: Handler) -> string` — inline-register shorthand; returns the id so it can be bound to a widget in one expression.
- `Router:dispatch(callbackId: string, data: any?) -> boolean` — look up and invoke a handler; logs a one-shot warning on unhandled ids.
- `Router:_resetWarnings()` — test-only: clear the unhandled-id warning gate.
- `Router:_wasWarned(id: string) -> boolean` — test-only: query whether an id has been warned.
Types:
- `Handler = (value: any, callbackId: string, ...any) -> ()`
- `RouterInstance` — the metatable-bound router object.
## Usage
```luau
local Router = require("@builtin::modules.zui.router")
local router = Router.create()
router:on("save:click", function(value, id) end)
router:on("cell:%d+:%d+", function(value, id, row, col) end)
router:dispatch("save:click", data)
```
## Notes
- Pattern detection is heuristic: strings containing any of
`% ( ) [ ] + * ? ^ $ .` are treated as Lua patterns. `-` is excluded
because it is common in literal ids ("system-tools-tab-entities").
- Handlers receive `(value, callbackId, ...captures)`. `value` is
pre-extracted via `Utils.eventValue(data)` so handlers don't unwrap
`data` manually.
- Unhandled ids log Warn exactly once per id; subsequent dispatches of
the same id are silent so a per-frame fired callback doesn't drown
the log.
# args
Shared argument resolver for zui container builders (`panel`, `vbox`, `hbox`, `grid`, …). Container builders historically took `(children, opts)` with children first, but the intuitive call is a single options table `Z.panel({ style = …, children = {…} })`. Passed to a `(children, opts)` builder, that table landed in the children slot and its `children` key was silently dropped. `resolve` removes that pit: it accepts every unambiguous shape and routes it correctly, and raises a precise error when a call is genuinely ambiguous instead of silently dropping content.
## Exports
- `resolve(builderName: string, a: any, b: any) -> ({ any }, { [string]: any })` — returns `(children, opts)` — an array of child widget tables (possibly empty) and an options table (possibly empty).
## Accepted shapes
```luau
resolve("panel", { childA, childB }, opts?) -- canonical array + opts
resolve("panel", singleChildNode, opts?) -- a lone widget (auto-wrapped)
resolve("panel", { style = …, children = {…} }) -- single options table
resolve("panel", { style = … }) -- options only (no children)
resolve("panel", nil, opts?) -- no children
```
## Errors (never silent)
- a children array that ALSO carries a `children` key
- an options-shaped first argument passed alongside a 2nd options argument
- `opts.children` that isn't an array
## Usage
```luau
local resolve = require("@builtin::modules.zui.widget.args")
local children, opts = resolve("panel", a, b)
```
# _rowCanvas
Shared selectable-row factory used by `Z.radioGroup` and
`Z.selectableList`. Each row is a single focusable `canvas` widget that
draws its background + optional radio glyph + label in widget-local
coords. Because the whole row is one canvas (not a composition of
glyph-canvas + label + container with click handlers), Tab cycles
row-by-row naturally via egui's focus chain, click commits selection via
the canvas's `onClick` prop, and arrow keys reach the parent group's nav
handler via the canvas's `onKey` prop. Underscore prefix marks it as a
package-internal helper — not exported from `zui.widget` and not part of
the `Z.*` surface.
## Exports
This module returns the row factory function directly. There is no
returned table.
- `rowCanvas(opts: RowOpts?) -> Node` — build a selectable-row canvas node.
Types:
- `RowOpts = { groupId?: string, index?: number, label?: string,
selected?: boolean, kind?: "radio" | "option", width?: number,
height?: number, bg?: string, fg?: string, selectedBg?: string,
selectedFg?: string }`
- `Node = { [string]: any }` — opaque canvas widget node.
## Usage
```luau
local rowCanvas = require("@builtin::modules.zui.widget._rowCanvas")
return Z.vbox{
rowCanvas{ groupId = "themes", index = 1, label = "Dark", selected = true, kind = "radio" },
rowCanvas{ groupId = "themes", index = 2, label = "Light", selected = false, kind = "radio" },
}
```
## Notes
- Click commits selection via `onClick = <groupId>:select:<index>`; the
parent group's callback subscribes to that event.
- Arrow keys raise `onKey = <groupId>:nav` so the parent group handles
keyboard navigation in one place.
- The whole row is a single canvas — focus moves row-by-row through
egui's focus chain naturally (no manual focus management needed).
- DOM mirror sees `<canvas role="radio"|"option"
aria-selected="true|false">` via the generic `role` + `aria*`
pass-through on canvas.
- Defaults: `200 × 22` row, `"#00000000"` bg, `"#dcdcdc"` fg, selected
`"#3a4a64"` bg + `"#ffffff"` fg.
# editorSelection
Named selection scopes for the editor. A scope is an independently
tracked, ordered set of typed refs plus a primary (the last ref added
or clicked). Distinct scopes never clobber each other, so viewport,
outliner, inspector and asset browser share one answer to "what is
selected" per kind.
A ref is `{ kind: string, id: string }` — a stable id, never a display
string, so rename/move never invalidates a selection.
## Exports
- `M.scope(name) -> Scope` — get/create a named scope handle.
- `M.set(scope, refs)` — replace refs in order; primary becomes the last.
- `M.get(scope) -> { Ref }` — refs in click order (fresh array).
- `M.primary(scope) -> Ref?` — last-clicked ref, or nil.
- `M.clear(scope)` — empty a scope.
- `M.toggle(scope, ref)` — add if absent, remove if present.
- `M.add(scope, refs)` — add each ref not already present; primary becomes the last added.
- `M.remove(scope, refs)` — remove each of `refs`; primary becomes the last remaining or nil.
- `M.contains(scope, ref) -> boolean` — membership by `(kind, id)`.
- `M.count(scope) -> number` — the scope's selection size.
- `M.subscribe(scope, fn) -> handle` / `M.unsubscribe(scope, handle) -> boolean`.
- `M.context() -> { scope, refs, primary }` — last-focused scope, for command ctx.
## Usage
```luau
local Selection = require("@builtin::modules.api.editor.selection")
local scope = Selection.scope("entity")
Selection.set(scope, { { kind = "entity", id = "player_1" } })
Selection.subscribe(scope, function() refreshInspector() end)
```
## Notes
- State lives in a fixed `_G` slot, seeded pre-seal by the boot chain
(`prelude.luau`); it survives hot-reload and edit↔play flips. Mutations
after boot write into nested tables only.
- `set` / `toggle` / `clear` mark their scope as last-focused, which drives
`context()` and therefore which scope commands act on.
- The service holds the editor's selection state in Luau. Refs handed back by
`get` / `primary` / `context` are copies, so a caller can hold or mutate them
without touching internal state.
# editorCommands
The command registry — the single place an editor action is declared.
One declaration is rendered by four surfaces: the menu bar, DataView
context menus, the command palette, and the keymap. `EditorRegistry`'s
`addMenuItem` is a thin wrapper over `declare`, so every menu action is
a command.
A command is `{ id, title, category, menu?, order?, keys?, enabledWhen?,
run }`. `ctx` passed to `enabledWhen`/`run` is the selection service's
`context()` augmented by the caller: `{ scope, refs, primary, view?,
item? }`.
## Exports
- `M.declare(cmd) -> boolean` — register/replace by id (duplicate replaces).
- `M.get(id) -> Command?`
- `M.list() -> { Command }` — sorted by (category, title).
- `M.remove(id) -> boolean`
- `M.isEnabled(id, ctx) -> boolean` — true when no predicate, else its result.
- `M.run(id, ctx) -> boolean` — runs only when enabled; returns whether it ran.
- `M.conflicts() -> { { keys, ids } }` — keys claimed by more than one command.
- `M.commandForKey(keys) -> id?` — the last declarer wins a contested key.
## Usage
```luau
local Commands = require("@builtin::modules.api.editor.commands")
Commands.declare({
id = "asset.delete", title = "Delete Asset", category = "Assets",
keys = "Delete",
enabledWhen = function(ctx) return #ctx.refs > 0 end,
run = function(ctx) ... end,
})
```
## Notes
- State lives in a fixed `_G` slot, seeded pre-seal by the boot chain; it
survives hot-reload and edit↔play flips. Mutations after boot write into
the nested `byId` / `keyClaims` tables only.
- A duplicate id replaces the prior declaration and rebinds its key.
- Conflicts are surfaced (`conflicts()`), never silently dropped; the last
declarer wins the live binding via `commandForKey`.
# _axes
Shared tick generation + axes + gridline drawing helpers for
canvas-hosted charts. Used by `Z.plot` for axes/gridlines/ticks and by
`Z.graph` for the optional axis labels + gridlines. Single source of
truth for the nice-tick algorithm so both widgets render identical-
looking tick scales. Underscore prefix marks it as a package-internal
helper — not exported from `zui.widget` and not part of the `Z.*`
surface.
## Exports
- `M.computeTicks(minV: number, maxV: number, nTicks: number?, customFormat: TickFormatter?) -> TickResult` — D3-style nice ticks. Snaps step to `{1, 2, 5} × 10^n`. `nTicks` default 5.
- `M.axisCommands(viewport: Viewport, bounds: Bounds, opts: AxesOpts?) -> { CanvasCommand }` — axes + tick marks + tick labels.
- `M.gridCommands(viewport: Viewport, bounds: Bounds, opts: AxesOpts?) -> { CanvasCommand }` — gridlines, one per major tick.
Types:
- `Viewport = { x: number, y: number, width: number, height: number }` — canvas-local pixel rect.
- `Bounds = { xMin: number, xMax: number, yMin: number, yMax: number }` — data-space range.
- `TickResult = { ticks: { number }, labels: { string } }`
- `TickFormatter = (number) -> string` — custom label formatter.
- `AxesOpts` — visibility + styling (`showXAxis`, `axisColor`, `labelSize`,
`tickLength`, `xTickFormat`, etc.).
- `CanvasCommand = { [string]: any }` — opaque canvas-renderer command.
## Usage
```luau
local Axes = require("@builtin::modules.zui.widget._axes")
local viewport = { x = 0, y = 0, width = 200, height = 120 }
local bounds = { xMin = 0, xMax = 100, yMin = 0, yMax = 1 }
local axisCmds = Axes.axisCommands(viewport, bounds, {})
local gridCmds = Axes.gridCommands(viewport, bounds, { gridY = false })
local r = Axes.computeTicks(0, 100) -- nice ticks: {0, 20, 40, ...}
-- Custom formatter:
Axes.axisCommands(viewport, bounds, {
xTickFormat = function(v) return "$" .. v end,
})
```
## Notes
- Coordinate convention matches canvas: origin top-left, `+y` down. Y
values from `bounds` are flipped at render time so `yMax` is drawn at
the top of the viewport, matching plot conventions.
- `mergedOpts` overlays caller opts on `DEFAULTS` and passes unknown keys
through — additive opts (`xTickFormat`, `yTickFormat`, future ones)
don't need bookkeeping in `DEFAULTS`.
- `computeTicks` caps iteration at 64 ticks to avoid pathological loops
on degenerate input.
- Out-of-range ticks (after the data → pixel mapping) are filtered
silently so neither labels nor lines bleed past the viewport.
# _viewport
Pan/zoom viewport helper for canvas-hosted charts. Persists
`{ xMin, xMax, yMin, yMax }` in `ui.widgetState(plotId, "viewport")` so
the bounds survive across renders; the first render seeds from
`defaultBounds`. Subsequent renders read the cached state; pan/zoom
handlers (`viewport:pan`, `viewport:zoom`) mutate the state in place and
write it back to widgetState. Underscore prefix marks it as a
package-internal helper — not exported from `zui.widget` and not part of
the `Z.*` surface.
## Exports
- `Viewport.make(plotId: string?, rect: Rect?, defaultBounds: Bounds?) -> ViewportInstance` — construct a viewport over `rect`. `plotId` enables widgetState persistence.
- `vp:dataToScreen(x: number, y: number) -> (number, number)` — data → canvas-local pixels.
- `vp:screenToData(sx: number, sy: number) -> (number, number)` — canvas-local pixels → data.
- `vp:pan(dxScreen: number, dyScreen: number, axisAllow: AxisFilter?)` — pan and write-back.
- `vp:zoom(factor: number, anchorScreen: ScreenPoint?, axisAllow: AxisFilter?)` — zoom around anchor and write-back.
- `vp:setBounds(bounds: Bounds)` — replace bounds and write-back.
- `Viewport.bounds(vp) -> Bounds` — snapshot bounds as a fresh table.
Types:
- `Rect = { x: number, y: number, width: number, height: number }` — canvas-local plot body.
- `Bounds = { xMin: number, xMax: number, yMin: number, yMax: number }` — data-space.
- `ScreenPoint = { x: number, y: number }`
- `AxisFilter = { x: boolean?, y: boolean? }`
- `ViewportInstance` — instance shape; carries `rect`, bounds fields,
`invertX/Y`, and the bound methods.
## Usage
```luau
local Viewport = require("@builtin::modules.zui.widget._viewport")
local vp = Viewport.make("plot1",
{ x = 0, y = 0, width = 200, height = 100 },
{ xMin = 0, xMax = 100, yMin = 0, yMax = 1 })
-- Inside onDrag handler:
vp:pan(dragDx, dragDy)
-- Inside onScroll handler:
vp:zoom(1 + scrollAmount * 0.1, { x = mx, y = my })
-- Reset:
vp:setBounds({ xMin = 0, xMax = 100, yMin = 0, yMax = 1 })
```
## Notes
- Coordinate convention: origin top-left, `+y` down. yMax in data space
maps to `rect.y` (top of viewport) so the cursor sits above the data
point during pan.
- Persistence is opt-in — passing `plotId = nil` skips widgetState reads
/ writes; bounds live only on the instance for the duration of the
call.
- Caller can reset by clearing widgetState explicitly:
`ui.widgetStateSet(plotId, "viewport", nil)`.
- `Viewport.bounds(vp)` is a free function (no colon) — it's a thin
snapshot helper, useful when you want a fresh bounds table without
mutating the instance.
- `invertX` / `invertY` flip the axis mapping in both `dataToScreen` and
`screenToData`; default `false` for both.
# _markers
Marker-shape canvas command generators used by `Z.plot.points`. Mirrors
the marker shapes that the deleted `egui_plot::MarkerShape` enum
exposed: `circle`, `square`, `diamond`, `cross`, `plus`, `up`, `down`,
`left`, `right`, `asterisk`. Unknown shapes fall back to circle with a
console warning (parity with the deleted Rust path).
## Exports
- `M.commands(shape: MarkerShape, cx: number, cy: number, radius: number, opts: MarkerOpts?) -> { CanvasCommand }` — list of canvas commands rendering the marker.
Types:
- `MarkerShape = "circle" | "square" | "diamond" | "cross" | "plus" | "up" | "down" | "left" | "right" | "asterisk" | string` — `string` fallthrough warns and falls back to circle.
- `MarkerOpts = { fill: string?, stroke: string?, strokeWidth: number? }`
- `CanvasCommand = { [string]: any }` — opaque canvas-renderer command.
## Usage
```luau
local Markers = require("@builtin::modules.zui.widget._markers")
local cmds = Markers.commands("diamond", 50, 50, 4, { fill = "#fff" })
-- cmds is a list of canvas commands ready to splice into a canvas.
```
## Notes
- Each shape returns one or more canvas commands centered at `(cx, cy)`
with bounding-circle radius `radius`.
- `cross`, `plus`, `asterisk`, `up/down/left/right` are stroke-based and
fall back to `opts.fill` (or `"#FFFFFF"`) for the line color when no
`stroke` is supplied.
- Unknown shapes warn via `log.warn` (when available) and fall back to
the circle marker — parity with the deleted Rust enum's default arm.
- Pure module — no engine state, no state owned here. Safe to call from
any context.
# canvas
Pre-built node-graph canvas shell — `Z.shell.canvas { width, height,
background?, nodes, connections, onSelectNode?, onMoveNode?,
onConnect? }`. Maps a node-graph state into Z.canvas paint commands
(background, bezier connections, rounded rect nodes with labels). The
module returns the shell function directly.
## Exports
- `canvasShell(opts: CanvasOpts?) -> canvas-widget` — render a node-graph canvas from a node/connection state.
Types:
- `Node = { id: string, x: number, y: number, label: string?, color: string? }`
- `Connection = { fromId: string, toId: string, color: string?, width: number? }`
- `CanvasOpts = { id?, width?, height?, background?, nodes?, connections?, onSelectNode?, onMoveNode?, onConnect? }`
## Usage
```luau
local canvasShell = require("@builtin::modules.zui.shell.canvas")
local widget = canvasShell {
width = 800,
height = 600,
nodes = { { id = "a", x = 10, y = 20 } },
connections = {},
onSelectNode = "graph:select",
}
```
## Notes
- Pointer events come through `onClick` / `onPointerMove` with the
widget-local `"x,y"` value. The caller is responsible for hit-testing
against `opts.nodes` in the App's `onCallback` to map the click to a
node id.
- `onConnect` (drag-from-A-to-B) is not yet wired — the canvas widget
doesn't surface drag-completion events. Tracked for a future update.
- Bezier connections use horizontal-tangent control points on the
mid-x line, producing smooth left-to-right flow between nodes.
# dockApp
A visibility-driven docked-app handle. Wraps `Z.app` with a per-panel refresh scheduler over the same tab contract as `Z.shell.tabApp`, but renders the open panels as `Z.dockPanel`s inside a single `Z.dockArea` (real egui_dock tabs, splits, and drag). The app costs zero engine work while hidden; each open panel polls on its own `refresh` interval, and the open model is an ordered set of open panel keys rather than a single active tab.
Each entry in `tabs` follows the tab contract `{ label, refresh, build, onMount?, onCallback?, tick?, float? }`. A tab's own `float` overrides the app-level `float` default — e.g. a viewport tab sets `float = false` to dock into the main surface while tool panels float.
## Create
`M.create(opts) -> DockAppHandle` — the module table is callable, so `DockApp(opts)` works too. Required `opts`: `name`, `tabs`, `tabOrder` (may be empty). Optional: `initialOpen`, `tabAliases`, `statusClock`, `onStatusTick`, `extraScreens`, `appOpts`, `initialState`, `dockLayout`, `float`, and dock presentation keys (`overlayType`, `leafCollapseButtons`, `leafCloseAllButtons`, `allowedSplits`, `dockStyle`).
## Handle
The returned `DockAppHandle` carries:
- `app`, `state`, `screenName`
- `update(dt)` — runs the scheduler and ticks the app.
- `onCallback(callbackId, data) -> boolean` — routes dock close, app dispatch, and tab/extraScreen callbacks.
- `destroy()`, `setVisible(v)`
- `openPanel(key)`, `closePanel(key)`, `isOpen(key) -> boolean`, `togglePanel(key)` — drive the open set.
- `getLayout() -> string?`, `restoreLayout(json)` — read and re-apply the live dock split/tab arrangement.
## Usage
```luau
local DockApp = require("modules.zui.shell.dockApp")
local handle = DockApp {
name = "system_tools",
tabs = TABS,
tabOrder = { "entities", "logs" },
initialOpen = { "entities" },
}
```
# docked
Pre-built docked-panel shell — `Z.shell.docked { top, left, center,
right, bottom }`. Captures the wrapper shape every demo's "toolbar +
body + status" layout reinvents: outer vbox with gap=0, theme bg, the
center slot flex-grows, and missing slots are skipped cleanly. The
module returns the shell function directly.
## Exports
- `dockedShell(opts: DockedOpts?) -> vbox-widget` — render a docked layout from top/left/center/right/bottom slots.
Types:
- `DockedOpts = { id?, class?, classes?, top?, left?, center?, right?, bottom?, background?, minWidth?, minHeight? }`
## Usage
```luau
local dockedShell = require("@builtin::modules.zui.shell.docked")
dockedShell { top = toolbar, center = body, bottom = status }
```
## Notes
- The center slot is stamped with `flex = 1` so it fills the middle
row horizontally. The caller's widget table is NOT mutated — its
style is shallow-cloned before stamping.
- `nil` slots disappear from the rendered tree. The middle row is
omitted entirely when left/center/right are all `nil`.
- Defaults: `gap = 0`, theme `bg` background, `minWidth = 100`,
`minHeight = 100`.
# inspector
Pre-built inspector pane — `Z.shell.inspector { title, fields,
actions }`. Captures the form-with-fields-and-actions pattern every
editor reinvents. Field editors are auto-selected from the value type;
`field.kind` overrides (e.g. for color swatches or free-form int/float
inputs). The module returns the shell function directly.
## Exports
- `inspectorShell(opts: InspectorOpts?) -> panel-widget` — render an inspector pane from a title + fields/sections + action buttons.
Types:
- `InspectorField = { label?, value?, onChange?, readonly?, kind?, hint?, id?, min?, max?, minWidth? }`
- `InspectorAction = { label?, onClick?, variant? }` — `variant in "primary" | "secondary" | "danger"`.
- `InspectorSection = { title?, fields?, defaultOpen?, id? }`
- `InspectorOpts = { id?, class?, classes?, title?, fields?, sections?, actions?, message? }`
## Usage
```luau
local inspectorShell = require("@builtin::modules.zui.shell.inspector")
inspectorShell {
title = "Entity",
fields = { { label = "x", value = 1, onChange = "x:set" } },
actions = { { label = "Apply", onClick = "apply", variant = "primary" } },
}
```
## Notes
- Editor selection: `boolean` → checkbox, `number` → slider (min/max
default 0/1), `kind = "int"|"float"` → free-form input, `kind = "color"`
→ hex button swatch, `string` → text input. Setting `readonly = true`
renders a label regardless of type.
- `sections` takes precedence over a flat `fields` list — each section
becomes a collapsible, expanded by default unless `defaultOpen = false`.
- Actions render right-aligned in a row at the bottom. The `variant`
field maps to theme tokens: `primary` → accent, `danger` → danger,
anything else → panel_alt.
# tabApp
Layer 5 shell — visibility-driven tabbed app. Wraps `Z.app` with a
per-tab refresh scheduler that gates ALL data fetches on
`(window_visible AND tab_active AND refresh_due)`, so the panel costs
zero engine work when hidden / closed / minimized and only the active
tab's `build()` is invoked when open. Lifted from `system_tools.module`
(the original consumer) so future live-data tools compose against this
surface instead of re-implementing the scheduler.
## Exports
- `M.create(opts: TabAppOpts) -> TabAppHandle` — build and mount a tab app. Required: `name`, `tabs`, `tabOrder`.
- `M(opts)` — the module table is callable; equivalent to `M.create(opts)`.
Types:
- `TabSpec = { label?, refresh?, build?, onMount?, onCallback?, tick? }`
- `ChromeOpts = { title?, layout?, minimizable?, closable?, minWidth?, minHeight?, maxWidth? }`
- `ExtraScreenSpec = { build?, refresh?, layer?, tags?, onCallback? }`
- `AppOpts = { layer?, tags?, ctx? }`
- `TabAppOpts = { name, tabs, tabOrder, tabAliases?, statusClock?, onStatusTick?, chrome?, extraScreens?, appOpts?, initialState?, callbackPrefix?, windowId?, closedWidth?, closedHeight?, minimizedWidth?, tabGap?, tabPadding? }`
- `TabAppHandle = { app, state, update, onCallback, destroy, setVisible, screenName }`
## Usage
```luau
local TabApp = require("@builtin::modules.zui.shell.tabApp")
local handle = TabApp {
name = "system_tools",
tabs = TABS,
tabOrder = { "entities", "logs" },
statusClock = 0.1,
onStatusTick = function(state) ... end,
}
-- Per-frame
handle.update(dt)
```
## Notes
- The scheduler keeps a single shared `schedClock` plus per-timer
`lastFire` timestamps. Stepping `lastFire` by the refresh interval
(rather than to `now`) keeps timers phase-locked to their mount time
forever — timers with matching intervals fire on the same frame.
- `extraScreens` may use either a `function(state)` shorthand or a
`{ build, refresh, layer, tags, onCallback }` table. The handle's
`onCallback` dispatches into App handlers first, then per-tab
`onCallback`, then extraScreen `onCallback`.
- The default chrome ships three shells (closed / minimized / open).
Pass `chrome.layout(state, opts) -> widget` to override entirely.
- `setVisible` flips `windowVisible` AND calls `ui.showScreen` /
`ui.hideScreen` for the bound screen name.
- `destroy` unmounts the App and resets the shared DataSource cache.
# json
JSON syntax highlighter. Pure `(text) -> { Segment }`. Mirrors the
algorithm of the deleted Rust `highlight_json` (renderer.rs): emits
object keys via lookahead-for-`:`, handles `\` escapes inside strings,
recognises numbers with scientific notation, and colors structural
punctuation (`{ } [ ] , :`). Colors flow from `ui.getToken("code.<role>")`
via the active theme; hardcoded fallbacks match the deleted Rust values
when no theme is registered.
## Exports
- `highlight(text: string) -> { Segment }` — tokenize a JSON string into colored segments for the zui code renderer. The module returns this function directly.
Types:
- `Segment = { text: string, color: string, monospace: boolean }`
## Usage
```luau
local highlight = require("@builtin::modules.zui.highlight.json")
local segments = highlight('{"a": 1, "b": "two"}')
```
## Notes
- Pure function — no side effects, no state. Safe to call any time.
- The `ui.getToken` lookup runs once per highlighter call (5 FFI calls)
rather than per byte, so the inner tokenize loop is allocation-light.
- Keys are disambiguated from string values by a lookahead past
whitespace for `:` — matches the deleted Rust algorithm exactly.
# lua
Luau syntax highlighter. Pure `(text) -> { Segment }`. Mirrors the
algorithm of the deleted Rust `highlight_lua` / `highlight_lua_code_part`
pair (renderer.rs pre-Phase-3): `--`-line comments, single- and
double-quoted strings (no escape handling), digit runs with optional
dots (no exponent), and identifiers dispatched against the Luau keyword
set. Colors flow from `ui.getToken("code.<role>")` via the active theme,
with hardcoded fallbacks matching the deleted Rust constants.
## Exports
- `highlight(text: string) -> { Segment }` — tokenize a Luau string into colored segments for the zui code renderer. The module returns this function directly.
Types:
- `Segment = { text: string, color: string, monospace: boolean }`
## Usage
```luau
local highlight = require("@builtin::modules.zui.highlight.lua")
local segments = highlight("local x = 1 -- pi-ish")
```
## Notes
- Pure function — no side effects, no state. Safe to call any time.
- The `ui.getToken` lookup runs once per highlighter call (5 FFI calls),
then locals are used in the hot tokenize loop — cost is per-call, not
per-byte.
- Strings have no escape handling (`"a\"b"` would terminate at the
middle quote). This matches the deleted Rust implementation exactly.
# markdown
Markdown syntax highlighter. Pure `(text) -> { Segment }`. Mirrors the
algorithm of the deleted Rust `highlight_markdown` /
`highlight_markdown_inline` pair (renderer.rs pre-Phase-3): block-level
pass over `split_inclusive('\n')` recognises fenced code (```),
headings (#), blockquotes (>), bullet/ordered lists; an inline pass
within each non-block line recognises inline code (`...`), bold
(**...**), and links ([text](url)).
## Exports
- `highlight(text: string) -> { Segment }` — tokenize a Markdown string into colored segments for the zui code renderer. The module returns this function directly.
Types:
- `Segment = { text: string, color: string, monospace: boolean }`
## Usage
```luau
local highlight = require("@builtin::modules.zui.highlight.markdown")
local segments = highlight("# Title\n**bold**\n")
```
## Notes
- The module-level color upvalues are reassigned at every highlighter
entry so the inline helper sees the active palette without per-call
args. Multiple concurrent calls are NOT safe — this module is
designed for serial UI rendering.
- Inline code reuses the `code.string` theme role; treat the two as the
same colour band.
- Fenced code (```) toggles a multi-line fence — the highlighter tracks
fence state across the input.
# wgsl
WGSL syntax highlighter. Pure `(text) -> { Segment }`. Mirrors the
algorithm of the deleted Rust `highlight_wgsl` (renderer.rs): `//` line
comments, `/* ... */` block comments, `"..."` strings with `\` escapes,
`@attribute` tokens, numeric literals (digits + dots + alphanumerics
for suffixes like `1u`, `0xFF`, `1.0_f32`), and identifiers dispatched
against the WGSL keyword and built-in type tables. Colors flow from
`ui.getToken("code.<role>")` via the active theme; hardcoded fallbacks
match the deleted Rust values when no theme is active.
## Exports
- `highlight(text: string) -> { Segment }` — tokenize a WGSL string into colored segments for the zui code renderer. The module returns this function directly.
Types:
- `Segment = { text: string, color: string, monospace: boolean }`
## Usage
```luau
local highlight = require("@builtin::modules.zui.highlight.wgsl")
local segments = highlight("@vertex fn main() -> vec4<f32> {}")
```
## Notes
- Pure function — no side effects, no state. Safe to call any time.
- Keyword and type tables match the deleted Rust constants verbatim
(renderer.rs:6245-6263).
- Strings are escape-aware (`"a\"b"` is one segment); WGSL has no
single-quoted strings.
# yaml
YAML syntax highlighter. Pure `(text) -> { Segment }`. Mirrors the
algorithm of the deleted Rust `highlight_yaml` / `highlight_yaml_content`
/ `highlight_yaml_scalar` trio (renderer.rs): per-line comment splitting,
`key: value` recognition (split on first `:`), and scalar typing
(quoted string / true|false|null / numeric / plain). Colors flow from
`ui.getToken("code.<role>")` via the active theme; hardcoded fallbacks
match the deleted Rust values when no theme is active.
## Exports
- `highlight(text: string) -> { Segment }` — tokenize a YAML string into colored segments for the zui code renderer. The module returns this function directly.
Types:
- `Segment = { text: string, color: string, monospace: boolean }`
## Usage
```luau
local highlight = require("@builtin::modules.zui.highlight.yaml")
local segments = highlight("name: zero\n# comment\n")
```
## Notes
- The module-level color upvalues are reassigned at every highlighter
entry so the inner scalar/content helpers see the active palette
without per-call args. Multiple concurrent calls are NOT safe — this
module is designed for serial UI rendering.
- Numeric detection allows `.`, `-`, `+` alongside digits, so dotted
versions and negatives parse as numbers (matches the Rust behaviour).
# cascade
Pure-Luau `$variable` cascade resolver for theme tokens and styles. Walks
a theme table's tokens and styles, expands every `$tokenName` reference
into a literal value with cycle detection, and returns a flat result.
Used by `Z.theme.register` so the engine receives fully-resolved
`(token, value)` and `(selector, prop, value)` pairs — no runtime
variable indirection. Cycle detection surfaces broken theme inputs as
structured `register` errors instead of silently leaving `Variable(name)`
in place and producing garbage colors.
## Exports
- `M.resolveValue(value, tokens, visited, order) -> (any, string?)` — single-value resolver. Exposed for tests; not part of the typed surface.
- `M.resolveTokens(theme: any) -> (TokenMap?, string?)` — flatten `theme.tokens`. Returns `(nil, errMsg)` on first cycle / unknown reference.
- `M.resolveStyles(theme: any, resolvedTokens: any) -> (StyleMap?, string?)` — flatten `theme.styles` against pre-resolved tokens.
Types:
- `TokenMap = { [string]: any }`
- `StyleMap = { [string]: { [string]: any } }`
- `Theme = { tokens: TokenMap?, styles: StyleMap? }`
## Usage
```luau
local Cascade = require("@builtin::modules.zui.theme.cascade")
local tokens, err = Cascade.resolveTokens(theme)
if err then error(err) end
local styles, err2 = Cascade.resolveStyles(theme, tokens)
if err2 then error(err2) end
-- `tokens` and `styles` now contain no `$variable` strings.
```
## Notes
- Pure module — no engine calls, no module state. Safe to invoke during
any phase (boot, test, runtime).
- Error messages include the offending token / selector / prop so cascade
failures are diagnosable from the structured error alone.
- `resolveStyles` resolves against pre-flattened `resolvedTokens` (not raw
`theme.tokens`) so chained references (`$a → $b → c`) are already
collapsed by the time style values are walked.
- Numbers, booleans, and arrays pass through unchanged — only strings
beginning with `$` are treated as references.
# icons
Phosphor icon constants. The phosphor font is registered as a named
family `phosphor` in the engine, so render icons by setting
`style.fontFamily = "phosphor"` on the label that contains them.
The font is ALSO appended to the proportional family as a fallback, but
the fallback path is unreliable for PUA codepoints in egui 0.34 (see
issue #2191) — always pass `fontFamily = "phosphor"` for icons.
Curated set covering DAW transport, media controls, file management,
desktop shell, editor chrome, and common state indicators. For icons
not listed here, see https://phosphoricons.com — pass the codepoint as
a UTF-8 string literal directly.
## Exports
- `Icon.styled(extra: LabelOpts?) -> LabelOpts` — build a `UI.label` opts table with `style.fontFamily = "phosphor"` applied. Merges with the supplied `extra`.
- `Icon.<Name>: string` — UTF-8 codepoint constants. Categories:
- Transport / media: `Play`, `Pause`, `Stop`, `Record`, `FastForward`, `Rewind`, `SkipForward`, `SkipBack`, `Repeat`, `Shuffle`.
- Audio / DAW: `Microphone`, `Headphones`, `Equalizer`, `Sliders`, `Metronome`, `MusicNote`, `Waveform`, `SpeakerHigh`, `SpeakerLow`, `SpeakerNone`, `SpeakerSimpleHigh`, `SpeakerMuted`.
- File / folder: `Folder`, `FolderOpen`, `File`, `FileText`, `FileAudio`, `FileVideo`, `FileImage`, `FileCode`, `FloppyDisk`, `Trash`, `UploadSimple`, `DownloadSimple`.
- Desktop / shell: `Desktop`, `DesktopTower`, `Monitor`, `Keyboard`, `Mouse`, `House`, `Sidebar`, `Cpu`.
- Editor: `Pencil`, `Plus`, `Minus`, `Check`, `MagnifyingGlass(Plus|Minus)?`, `Gear`, `Power`, `Eye`, `EyeSlash`, `Lock`, `LockOpen`, `Bell`, `Palette`, `Funnel`, `Clipboard`.
- Code / dev: `Code`, `Terminal`, `Bug`, `GitBranch`, `GitCommit`, `GitMerge`, `Database`, `Cloud`.
- Arrows / nav: `ArrowUp/Down/Left/Right`, `ArrowsClockwise`, `ArrowsIn`, `ArrowsOut`, `CaretUp/Down/Left/Right`.
- Layout / list: `List`, `GridFour`, `DotsThree`, `DotsThreeVertical`.
- State / status: `Info`, `Warning`, `WarningCircle`, `CheckCircle`, `XCircle`, `Question`, `Heart`, `Star`, `Tag`, `Hash`, `At`.
- Shapes: `Circle`, `Square`, `Triangle`.
- Productivity / shell apps: `Calculator`, `Notepad`, `NotePencil`, `Note`.
- Games / cards: `GameController`, `Bomb`, `Cards`, `Diamond`, `Spade`, `Club`.
Types:
- `LabelStyle = { fontFamily?: string, fontSize?: number, color?: string, [string]: any }`
- `LabelOpts = { style?: LabelStyle, [string]: any }`
## Usage
```luau
local Icon = require("@builtin::modules.icons")
UI.label(Icon.Play, { style = { fontFamily = "phosphor", fontSize = 24 } })
UI.label(Icon.Folder .. " Documents", {
style = { fontFamily = "phosphor", fontSize = 14 },
})
-- Or use the helper to enforce the phosphor family:
UI.label(Icon.Play, Icon.styled({ style = { fontSize = 24, color = "#fff" } }))
```
## Notes
- The constants are plain string values — each is a single UTF-8 Private
Use Area codepoint. Concatenate freely with surrounding text.
- `Icon.styled` mutates the supplied opts table (`extra.style` is
forced). Pass a fresh table per call site if you need to reuse opts.
- Curated subset only — for icons outside this set, paste the codepoint
from https://phosphoricons.com directly.
# editor_observe
What an editor action committed, and why it committed less than it was asked for.
Every editor action — a gizmo drag, a delete, a duplicate, a command dispatch, a
selection gesture — closes by publishing one record here, and returns that same
record to its caller. Nothing is measured per frame: a record is written where
the work happens and read lazily.
```lua
local Obs = require("@builtin::modules.api.editor.editor_observe")
Obs.observe() -- the whole document: last of each kind, history, counts
Obs.last() -- the most recent action of any kind
Obs.last("drag") -- the most recent drag
Obs.history() -- every retained action, oldest first
```
The same reading is a tool: `tools.use("editor", "observe")`.
## What every record carries
- `action` — `drag`, `grab`, `delete`, `duplicate`, `command` or `select`.
- `outcome` — `committed`, `partial`, `refused`, `cancelled` or `noop`.
- `reason` — the nearest cause, from the closed set below, when the outcome is
anything but a clean commit.
- `detail` — the engine's own words for that cause.
- `entities` — one row per entity the action touched or tried to, each with
`before`, `requested` and `after`, and its own `reason` when it is not a clean
commit.
- `committed` / `changed` / `refused` — how many rows fall in each.
- `seq`, `atMs`, `durationMs` — which action this is and how long it was open.
## The reason set
`selectionEmpty`, `entityMissing`, `writeRefused`, `writeDiverged`, `pivotLost`,
`userCancelled`, `commitFailed`, `duplicateRefused`, `despawnRefused`,
`unchanged`, `commandMissing`, `commandDisabled`, `predicateRaised`,
`bodyRaised`, `hitNothing`, `pointerBlocked`. `Obs.REASONS` maps each to its
one-line meaning, so a caller can enumerate the set rather than guess at it.
## A drag that moved less than asked
The drag record separates the three quantities that are usually conflated:
- `pointerAsked` — what the pointer's position asked for, before snapping.
- `applied` — what the gizmo handed the engine, after snapping. `snapping` and
`snapIncrement` say why the two differ.
- each entity's `after` — the transform the engine **holds**, read back from the
engine rather than recomputed from the drag's own arithmetic.
An entity whose `after` differs from its `requested` reads `writeDiverged`; one
whose write raised reads `writeRefused` with the message; one despawned mid-drag
reads `entityMissing`. A drag that ended on Esc reads `cancelled` /
`userCancelled`, and one whose selection emptied under it reads `cancelled` /
`pivotLost` — three terminal states a single "the object did not move" cannot
tell apart.
## A drag that never began
A press that lands on a handle and starts no drag publishes a `grab` record
instead — `Obs.last("grab")`, or `Gizmo.lastGrab()`. `pointerBlocked` says the UI
layer held pointer focus, so the press never reached the handle. A press away
from every handle is a selection click rather than a grab, and records nothing.
One record per press: a frame that ticks the gizmo more than once reports the
press once.
`unit` names what `pointerAsked` and `applied` are measured in: `metres` for a
translate or plane drag (a world-space `{x,y,z}` delta), `radians` for a rotate
(`{radians}`), `factor` for a scale (`{factor}`).
## Per-frame cost
The editor's own per-frame work is named in the profiler rather than pooled into
`lua_update`: `script.editor.gizmo.tick`, `script.editor.viewport.select`,
`script.editor.selection.highlight`, and `script.editor.panel.<id>` for each
dock panel's rebuild. Read them with `profiler.stats()`.