# 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.
# 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.
# 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,
})
```
# 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.
# 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.
# 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.
# 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.
# 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.
# 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.
# 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.
# 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.
# 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).
# 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.
# 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.
# 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).
# 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.
# 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.
# 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.
# 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`.
# 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)
```
# 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.
# 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.
# 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).
# 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.
# 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.
# 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`.
# rightPanel
Right-docked panel. Root-only — must be the top-level widget of its
registered screen.
## Exports
- `rightPanel(children: { any }?, opts: RightPanelOpts?) -> any` — build a right-docked panel widget. The module returns this function directly.
Types:
- `RightPanelOpts = { id: string?, classes: (string | { string })?, class: (string | { string })?, props: { [string]: any }?, style: { [string]: any }? }`
## Usage
```luau
local rightPanel = require("@builtin::modules.zui.widget.rightPanel")
rightPanel({ child1, child2 })
```
## Notes
- Root-only: must be the top-level widget of its registered screen.
Nesting a `rightPanel` inside another container is unsupported.
# 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.
# 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.
# 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.
# 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
```
# 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
```
# 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.
# 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.
# 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`.
# 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.
# 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.
# 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.
# 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).
# _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.
# 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()`.