Elements
What a script builds from: boxes, text, controls, web views, terminals, state, plugins, testing and shipping.
Elements take one table: string keys are properties, events and options; array entries are children (nodes, strings as text, functions as computed text, nested arrays, which flatten). nil and false entries are skipped, so cond and node or nil works anywhere in the list.
The component library’s elements (ui.data_grid, ui.tree, ui.calendar, ui.date_picker, ui.time_field, ui.combobox, ui.select, ui.tabs, ui.command_palette, ui.segmented, ui.stepper, ui.disclosure, ui.accordion and ui.separator, and ui.toast) are zgx-components’, through zgx-script’s components feature, on by default: a host built without it (default-features = false) has the rest of ui.
| Function | |
|---|---|
column, row, div, overlay, button, grid | containers (grid sets grid = true) |
scroll, scroll_x | scroll views; offset = state binds the position in points; follow = true keeps the view at its end while the content grows, until it is scrolled away (a transcript) |
text "…", text { "…", … }, text(fn) | {name} interpolates state in Rust; {{ }} are literal braces |
rich { "plain", span { "bold", bold = true, on_click = … } } | inline runs: color bg text_size weight bold italic underline strike font on_click href |
markdown_view { text, breaks, paths, copy } | markdown drawn (code blocks with Copy, the theme’s colours), parsed as its text changes and only its changed blocks built again: a streaming reply’s view (reference) |
markdown(text, { key, breaks, paths }) | markdown parsed into blocks of runs to draw your own way; with key, text that grows (a streaming reply) parses only its new end and keeps its earlier blocks the same tables (reference) |
image { src = "a.png", fit = "cover" } | PNG or JPEG, relative to the script; src may be state |
svg { src = "icon.svg" }, svg { code = "<svg…>" } | tint = color (a state or function re-tints it); tint = "currentColor" follows the text color it inherits (a button’s, hover included) |
canvas { draw = function(c, w, h) … end } | runs again when its size or the state it reads changes |
canvas { each = state, draw_item = function(c, item, w, h) … end } | draws each item on its own and keeps it: only items written since are drawn again |
webview { src = "https://…", … } | a native web page (WKWebView on macOS) laid out, rounded, clipped and faded like any node, with zgx content (frosted popovers too) drawn over it; see Web views |
terminal { term = ui.term(id) } | a terminal session (a Ghostty surface, with the desktop host’s ghostty feature) placed like a web view; see Terminals |
checkbox, input, textarea, progress, slider | { label, value = state }; slider takes min, max; input and textarea take placeholder (text shown while the value is empty: a string, state or function), placeholder_color and select_on_focus = true (focus selects all the text: an address bar) |
show(cond, node, else) | cond: state, function or boolean. What it shows lays out as if it stood in its place (its grow, self, percentages and absolute insets reach the parent), as for switch and presence |
switch(state, { key = node, … }, default) | |
presence(value, function(v) … end, { enter, exit, mode, initial, layout }) | shows what value (a state or function) builds; when it changes, the old content leaves out of the flow, where it was and as large, playing exit (anime.js parameters), while the new plays enter. mode = "wait" enters after the exit; nil shows nothing; initial = true plays enter on the first value too. Sizing in its options (w, h_full, grow, p, …) makes it a box its content fills. A new value builds its content again, as a new key does in React: when the content can update in place (the open message’s pane, as another opens), key it by whether something shows, not which one (if open:get() then "open" else nil), its texts reading what shows |
each(list, function(item, i) … end, { key = fn, animate, enter, exit, initial }) | rows follow their items (without key, by value), keeping their state across reorders, inserts and removals; a row whose item changes is built again, or with function(item, i, row) gets its item as state and updates in place; animate = true slides rows the list moves from where they were, enter and exit play on rows that come and go, as for presence (see Layout animation) |
list { count, height, row = function(i) … end, offset } | virtualized: only visible rows are built, each owning its ui.use_state and effects as a component does; with key = function(i) … end a row follows its item (row = function(key, index), index a state), moved rather than written again as items are removed or inserted; with measured = true rows take their content’s height (wrapped text), measured as they mount |
modal { open = state }, popover { open, anchor }, menu { open, anchor }, menu_item { "Copy", on_click }, submenu { open } | overlays |
slot(name), contribute(name, node) | plugin extension points |
Canvas drawing: c:rect(x, y, w, h, paint, radius), c:circle(x, y, r, paint), c:line(x1, y1, x2, y2, paint, width, opts), c:fill(path, paint, "evenodd"?), c:stroke(path, paint, width, { cap, join, dash, dash_offset, draw }), c:clip(path), c:restore(). Paths: ui.path():move(x, y):line(…):quad(…):cubic(…):arc(cx, cy, r, start°, sweep°):close(). Paint is a color or ui.linear(x1, y1, x2, y2, { colors }). draw = t (or { start, end }) strokes a fraction of the path, as anime.js draws lines.
Web views
ui.webview shows src (a URL, state or function: the page navigates when it changes) or html with base_url. Its options make the page: transparent, profile ("default" shares cookies and storage, "ephemeral" keeps nothing, any other string is a named, persistent store), inspectable, user_agent, messages and new_windows. To keep a page across the node (a tab switched away and back) or drive it from elsewhere, make its controller with ui.web(options) and pass web = controller. ui.web(id, options) keeps the page in the host by id, as ui.term keeps sessions: a reload asking for id again gets the same page, history and all, until web:close().
local web = ui.web { profile = "ephemeral" }
local url = ui.state("url", "https://example.com")
return ui.column {
ui.row { ui.button { "Back", on_click = function() web:back() end }, ui.text { function() return web.title end } },
ui.webview {
web = web, src = url, grow = 1, rounded = 12,
on_navigate = function(e) print(e.kind, e.url) end, -- started, committed, finished, failed, changed
on_message = function(value) print(value.kind) end, -- window.zgx.postMessage({ kind = … }) from the page
},
}
The controller’s url, title, loading, progress, can_go_back, can_go_forward, focused, error and pointer are tracked like state. pointer is { x, y } in points from the page’s top left while the pointer is over the page’s visible box (zgx content drawn over it included), nil otherwise: over a page that takes input zgx’s nodes have had their leave and hear no moves (as over a terminal, whose term.pointer is the same), so a page’s own edge affordances follow web.pointer. Its methods: load(url), html(html, base_url), back(), forward(), reload(), stop(), focus(), blur(), devtools(), zoom(z), run(js) and eval(js, function(value, err) … end), which gets the script’s value as Luau data. Events: on_navigate({ kind, url }), on_load(url), on_title(title), on_message(value), on_error(url, err) and on_new_window(url). Without a native host (headless, other platforms) the node shows a labelled box.
Terminals
ui.term(id, options) returns the terminal session for id, starting it the first time: command (a command line; the login shell by default), cwd ("~" is home), env (a table) and font_size. Sessions belong to the host, not to a run: a reload keeps every shell running and the run that asks for id again gets the same session, its options’ handlers replacing the previous run’s (ui.term(id) without options only looks it up). Its process starts when it is first shown, with the options given by then: a lookup that comes first (a sidebar reading every pane’s state) does not decide how it starts. Show one with ui.terminal { term = t, … }, sized and rounded like any node; moving the node elsewhere (another split, another tab) moves the terminal with its process.
local term = ui.term("p1", {
cwd = "~/src",
on_title = function(title) print(title) end,
on_close = function(alive) term:close() end, -- the process exited (alive = false)
})
return ui.terminal { term = term, grow = 1, rounded = 10, on_focus = function() print("typing here") end }
The session’s title (what the program set), pwd (the shell’s directory), process (the foreground program’s name), pid, focused, bell, progress, exited, columns and rows are tracked like state. Methods: focus(), blur(), input(text) (as typed), paste(text), binding(action) (a Ghostty binding action: "copy_to_clipboard", "clear_screen", "increase_font_size:1"), needs_confirm() (whether closing ends something other than an idle shell), snapshot(max_width?) (a picture of what it shows now, as a ui.image src) and close(). Events, as options: on_title, on_pwd, on_process, on_exit(code), on_close(alive), on_bell, on_command(exit_code, ms) (shell integration) and on_notify(title, body). progress_error says the last progress report (OSC 9;4) was an error. status is what the program reports of itself through the Program Status Protocol (OSC 7501), when it does: state ("idle", "working", "done", "blocked", "error"), kind (what a blocked program waits for: "permission", "question", "auth"), progress, app, title, msg, child_blocked (a sub-task is blocked) and since (Unix seconds); nil otherwise. A terminal host fills it in; zgx-desktop’s terminal example keeps a session’s reports in its session server. The terminal reads its own keyboard, IME and mouse; app shortcuts reach the app through its menus (see Commands). zgx-desktop/examples/terminal is a multiplexer written this way.
Commands and host actions
ui.command { id, title, menu, key, run } declares an app command: the host shows it as a menu item (ScriptHost::commands, in declaration order) with key as its shortcut ("cmd+shift+d", "cmd+alt+arrowleft", "cmd+[") and calls run when it is chosen (ScriptHost::run_command). Menu shortcuts work wherever focus is, a terminal or a web page included. ui.command { menu = "View", separator = true } adds a separator. Each successful load replaces the commands; a failed one keeps the last. ui.invoke(name, arg) calls a host action now, from any handler (ui.action binds one to a handler instead). Lua tables drop keys set to nil; ui.null keeps one, as JSON’s null: { caps = ui.null } reaches the host as {"caps": null}.
Drag and drop
A node becomes a drag source with on_drag = function() return payload end (any plain value), dragged once the pointer moves past the system’s drag threshold (GNOME’s and KDE’s setting, GTK’s 8 points on Linux without one, Windows’ SM_CXDRAG, 4 elsewhere); drag_preview = function(payload) return node end builds what follows the pointer, and on_drag_end(payload, accepted) reports how it ended. A destination takes on_drop(payload, e), optionally drop_when(payload) to accept only some payloads, and on_drag_over(payload, e) / on_drag_leave(payload) to show where a drop would land; e is { x, y, local_x, local_y, w, h }. on_drag_spring(payload) spring-loads it: the drag rested on it for the system’s delay (or Space was pressed), so it opens what it stands for, as a tab shows. drag_preview_anchor = { 0.5, 0 } with drag_preview_offset = { 0, 16 } hangs the preview below the pointer. The nearest accepting destination gets the drag. A node with on_press and on_pointer_move or on_release keeps the pointer from press to release, wherever it goes (a divider being dragged).
Context menus
context_menu = function(e) return { … } end on any node shows what it returns (ui.menu_items, ui.submenus, separators such as a 1-point ui.div) as a menu at the pointer on a secondary click, or a control-click on macOS; returning nothing shows none and lets the click through. e is { x, y, local_x, local_y }. The innermost node with a context menu takes the click. context_menu_style = { … } styles the menu’s panel with plain values (bg, backdrop_blur, rounded, p, w, border, shadow). context_menu_below = true opens it under the node, from its left edge, as a tab’s does, wherever the click was. Terminals hand right clicks their program does not use to the node, so a terminal’s context menu opens over it. Rust views have the same in View::context_menu, View::context_menu_style and View::context_menu_below.
Properties
Any property can be a value, a state, a function (rerun when what it reads changes) or an animation binding.
- Size and position:
w h min_w max_w min_h max_h(points,"50%"),size w_full h_full aspect absolute relative top left right bottom;snap = truelays the node’s edges on whole points after layout (percentages and ratios leave fractions, which blur a 1-point border across pixels), its size following its rounded edges so boxes sharing an edge still meet. - Spacing:
p px py pt pr pb pl, marginsm mx my mt mr mb ml(also"auto"),gap gap_x gap_y. - Flex and grid:
direction grow shrink basis flex_wrap wrap_reverse items justify self content,grid cols rows col_start row_start col_span row_span col_span_full row_span_full. - Paint:
bg gradient color rounded corners border border_color border_dash shadow opacity blur backdrop_blur fade layer fit tint cursor,overflow overflow_x overflow_y hidden invisible.blurblurs the node’s own content (CSSfilter: blur());brightness,contrastandsaturate(CSS filter functions, 1 is unchanged) recolor the node and its subtree without a layer, and animate on the compositor (hoverbrightening:anime.animate(el, { brightness = 1.12, duration = 150 }));backdrop_blurblurs what is behind it (frosted glass).fade = { top, bottom },fade_x = { left, right }orfade_sides = { top, right, bottom, left }fade a clipping view’s edges over those widths (a horizontal row fading its right 28 points:fade_x = { 0, 28 });fade_overflow = truefades an edge only as far as content runs past it, so a list scrolled to its top shows no top band and the band grows in as the content scrolls under that edge. - Background images:
bg_image = "photo.jpg"(a path, state or function) withbg_fit = "cover"(default),"contain","fill","none"or"scale_down": drawn overbg, under the border and children, cut byrounded/corners. - Masks:
mask = { ui.bitmap(w, h, function(x, y) return alpha end), w = …, h = …, x = …, y = … }shows the content through the bitmap’s alpha, drawn atw×hfrom (x,y), sampled without smoothing. AnimatingmaskX/maskYslides it on the compositor (a dithered fill, a sprite strip stepped withease = "steps(n)"); for a band sweeping on and on (a text shimmer),sweep = { x = 640, duration = 2000, frame_rate = 30 }in the mask has the engine move it on its own frame clock: no script, style pass or layout per step, only the masked node composited again. - Shader fills:
bg_shader = ui.shader(wgsl, { fps = 30 })fills the box with a WGSL function run per pixel on the GPU, overbg, under the border and children, cut by the corners. The source definesfn fill(input: FillInput) -> vec4<f32>(straight alpha) and readsinput.p(points from the box’s top left),input.size,input.time(seconds),input.scale(pixels per point) andinput.params(fourvec4s).shader_params = { "#e2c98f", { 1, 0.5, 0, 0 } }sets the parameters (colors become 0 to 1; a theme token,"$accent", follows the theme); animateshaderTimeandshaderParam0toshaderParam15like any property, on the compositor (anime.animate(el, { shaderTime = { 0, 60 }, duration = 60000, ease = "linear", loop = true, frameRate = 12 })turns a dither at 12 fps).fpscaps how often time repaints it. A shader that does not compile fills withbg. - Clip insets:
clip_inset = { top, right, bottom, left }(CSSclip-path: inset(...)) paints the node and its children only inside its box shrunk by those amounts (negative reaches past it, rounded with the node’s corners when it clips). Animate the sides asclipTop,clipRight,clipBottom,clipLeft, in points or as a percentage of the node’s size:{ clipBottom = { 0, "100%" } }closes a node upward while its space closes, on the compositor. - Size animation:
layout = "height" | "width" | "size"lets the node’s content size change freely while the space it takes eases to it (a spring of 0.4 s, bounce 0.1, orlayout_ease/layout_duration). The node lays out at its new size at once, as it would withoutlayout(itsgrow, percentages and the scroll views inside it too), and is not clipped (layout_clip = trueclips it to the space);layout_enter = truegrows it from nothing when it mounts. Combine withpresencefor content that swaps, folds or docks. - Position animation:
layout = "position"slides the node when layout moves it within its parent, andlayout_id = "selection"hands a node’s box to the next node mounted with the same id; see Layout animation. - Text:
text_size weight bold italic align font wrap truncate lines line_height letter_spacing. - Transforms:
x y scale scale_x scale_y rotate skew skew_x skew_y origin(degrees; origin as fractions of the node). hover,active,focus,disabledtake tables of plain values;disabled = statedisables.selectable = truemakes the text in the node and everything it holds selectable across nodes, as on a web page: drag, double/triple press, Shift, ⌘A / Ctrl+A, ⌘C / Ctrl+C and Copy in the context menu on selected text;falsekeeps a part out. Text is not selectable by default (put it on the node that draws aui.markdowndocument). See selectable text.window_drag = truemakes the node a window drag region (CSS’s-webkit-app-region: drag): a press on it, or on anything inside it that takes no input itself, moves the window, and a double click zooms or minimizes it as the system’s title bar setting says. Buttons, inputs and other focusable or pointer-following nodes inside keep their clicks. WithWindowOptions { titlebar: Titlebar::Inset, glass: Some(Glass::default()), .. }in the host, awindow_dragstrip under the traffic lights is a whole frosted title bar.id,class = "a b",ref = ui.ref()make a node reachable by selectors and animations.pointer_events = "none"(CSSpointer-events: none) lets the pointer through the node, and through descendants without handlers of their own, to what is under it: a full-window layer that holds popovers and tooltips, a hover card over a chart.
Lua tables have no key order, so a property that refines another applies after it whatever order the keys come in: p = 0, px = 8 pads the sides, border = 1, border_sides = { 1, 0, 0, 0 } draws only the top, and rounded gives way to corners and rounded_tl.
Colors are "#rgb", "#rrggbb", "#rrggbbaa", rgb(), rgba(), hsl(), a few names, 0xrrggbb, or a theme token, "$text.secondary" (lengths, sizes, fonts and shadows take tokens too; see Theming). gradient = { angle = 135, stops = { "#f80", "#08f" } } (CSS linear-gradient angles: 0 runs bottom to top, 90 left to right, the default 180 top to bottom; stops may be { offset, color }); shadow = { x = 0, y = 8, blur = 24, spread = 0, color = "#0006" } (keyed) or a list of them (CSS box-shadow values: blur is the CSS blur radius); corners = { tl, tr, br, bl }, or single corners rounded_tl rounded_tr rounded_br rounded_bl (animatable as borderTopLeftRadius and so on); border_sides = { top, right, bottom, left } sets each side’s width (a dock open at the bottom: { 1, 1, 0, 1 }); border_over = true paints the border over the node’s children (an image’s frame) instead of under them.
Boxes and positioning
Sizes are border-box, as in CSS with box-sizing: border-box: w and h include the border, padding and children sit inside it (each side of border_sides on its own), and a node sized by its content adds its border. ui.column { w = 200, p = 8, border = 1 } lays its children out 182 wide, 9 in from each edge. Only border_over = true (an inset stroke over the content) takes no room, so use it for a ring that must not move content; a transparent border still takes its width. The default focus ring of buttons and inputs is such a stroke (or recolours their own border); focus = { border = 0 } turns it off.
ui.overlay stacks its children in one box, as CSS grid lays out items sharing one area: each is sized against the overlay and aligned in it on its own, items across ("start" "center" "end" "stretch") and justify down (the same, plus "between" as start and "around"/"evenly" as centre). With neither set, children stretch to fill it, except along an axis they size themselves on; items = "center", justify = "center" centres an icon in a tile. The overlay is as large as its largest child, so as a pane (grow = 1 in a row) it shows children that ask to fill it. ui.scroll and ui.scroll_x are 320 by 240 only when nothing else sizes them: a stretching parent stretches them across, grow grows them, w/h hold; their content fills their width (scroll) or height (scroll_x). ui.input and ui.textarea are 240 wide by default whatever their text (a long value scrolls inside, keeping the caret in view), widen in a stretching parent and shrink in a row too narrow for them, as every box does (shrink = 1, CSS’s default, each no narrower than its content needs; an input scrolls its text, so it may shrink to nothing); shrink = 0 holds one.
An absolute = true node positions against its containing block: the nearest ancestor with relative = true, searched from its parent up but not past an absolute ancestor (which contains its own absolute descendants); with none, its parent. left, top, right and bottom count from that block’s padding box, inside its border; percentages are of its size; both insets on an axis without a size stretch it between them; an axis without insets keeps it at its parent’s content origin (plus ml/mt). show, switch, each and presence wrap their content in a node of their own, so mark the intended ancestor relative rather than relying on being its direct child. Transforms and scroll offsets never change the containing block: an absolute node moves with them, and still paints in its parent’s place, under its clips.
ui.column { relative = true, w = 300, h = 200,
ui.column { p = 12, ui.div { absolute = true, right = 8, bottom = 8, w = 16, h = 16 } } }
Responsive layout
A layout that changes with the room it has, as CSS’s media and container queries change one, reads its container’s size through a ref: r.width and r.height are followed, so a computed reading them runs again when layout resizes the element. The change lands in the frame the size changes: layout, the computed and layout again all run before it shows, as a browser settles its ResizeObserver callbacks before painting, so nothing flashes at the old layout.
local root = ui.ref()
-- "wide", "medium" or "narrow", by the window's width.
local layout = ui.computed(function()
local w = root.width or 1200
return if w >= 1000 then "wide" elseif w >= 680 then "medium" else "narrow"
end)
return ui.row {
ref = root,
w_full = true,
h_full = true,
ui.show(function()
return layout:get() == "wide"
end, ui.column { w = 220, shrink = 0, "Mailboxes" }),
ui.column {
grow = 1,
-- Wraps onto a second line rather than holding the pane wide.
ui.row { flex_wrap = true, gap = 8, ui.button { "Reply" }, ui.button { "Archive" } },
},
}
Query a box whose size does not depend on what the query changes, such as the window’s root row: a box sized by its content that hid content below a width would go back and forth (zgx stops after a few rounds). Within a layout, flex does the rest: children shrink to fit (shrink = 1) down to their content’s narrowest, text wraps with wrap = true, rows with flex_wrap = true, and min_w = 0 lets a truncated label go narrower still. The mail example (examples/mail) folds a three-pane window this way, into a drawer and then one pane at a time.
Layout animation
Three things animate what layout does, all the same way: after the layout pass that moves a node, it is held where it showed by a translation, which eases to nothing. The frames that follow only move it on the compositor: no layout, no script, and frames only while something moves. Positions come from layout, so scrolling and other animations are not moves, and a move in flight that is interrupted continues from where the node shows. The default ease is the layout boxes’ spring (0.4 s, bounce 0.1).
ui.each(list, build, { key = fn, animate = true }): when the list changes, rows that stay but move slide from where they were, whatever their sizes, in a column, a row, a wrap or a grid (the list’s owngrid = true, cols = 2).animatetakestrue, an ease name,{ duration, ease }(ms, a name oranime.spring),{ bounce, duration }(a spring) or{ spring = anime.spring { … } }. New rows playenterand leaving rowsexit, with the parameterspresencetakes (initial = trueplaysenteron the first rows too); a leaving row leaves the flow where it was, so the rows after it close up while it goes. Rows need akeyto be the same rows after a change.layout = "position"on any node: when layout moves it within its parent (a sibling grows, one before it closes), it slides there.layout_easeandlayout_durationtime it.layout_id = "selection": a node mounting while another with the same id is mounted, or as it unmounts in the same frame, starts at that one’s box (translated and scaled) and moves to its own: a tab’s selection highlight sliding to the tab that takes it. Ids are scoped to the script. The node also slides when layout moves it, as with"position";layout_easeandlayout_durationtime both.
ui.each(tabs, function(tab)
return ui.row {
px = 10, h = 28,
-- Under the tab's content, filling it: the presence is placed.
ui.presence(function() return if active:get() == tab.id then "on" else nil end, function()
return ui.div { layout_id = "selection", h_full = true, rounded = 8, bg = "#ffffff14" }
end, { absolute = true, top = 0, left = 0, right = 0, bottom = 0 }),
ui.text { tab.name },
}
end, {
key = function(tab) return tab.id end,
animate = { duration = 240, ease = "outQuint" },
exit = { opacity = 0, duration = 150 },
})
The moves go on the node’s translation over its own: a row’s x and y (literal or animated) compose with them. A shared element’s scale is the node’s own scale, so animating its scale while it hands over fights the handover.
Events
on_click, on_press (primary pointer-down), on_release, on_hover(inside) (true once when the pointer comes inside the node or anything in it, false once when it is outside it and all its descendants: moving between children tells the parent nothing, as the DOM’s mouseenter/mouseleave; leaving the window, the window going to the back and the pointer moving onto a web page or a terminal leave too), on_pointer_move({ x, y, local_x, local_y }), on_key({ key, shift, ctrl, alt, meta, repeat }) (return true to consume it), on_key_up(key), on_focus, on_blur, on_wheel({ dx, dy }), on_mount(element), and on_resize({ w, h }, element), called when layout gives the node a new size (and at its first layout), as a ResizeObserver reports: decide from real sizes (a row that wraps, text that overflows) rather than estimates; and on_visible(visible, element), called when any of the node comes into view or all of it leaves (inside every clip and scroll viewport above it), and at its first layout: build what only matters on screen. Elements read their laid-out width, height, x and y. el:focus() moves keyboard focus to the element or its first shown focusable descendant, and el:blur() clears focus inside it. An input’s element selects: el:select_all(), el:set_selection(a, b) (b defaults to a, a caret) and local a, b = el:selection() (the anchor and the focus, where the caret is), in characters: Unicode code points from 0, as utf8.len counts them, so el:set_selection(0, utf8.len(text)) selects everything. They return false (selection: nil) for other elements. Handlers take a function or a Rust-run action: ui.set(s, v), ui.toggle(s), ui.add(s, n), or ui.action(name, arg) for a function registered with ScriptHost::action.
State and components
requireloads modules as Luau resolves them:require("./card")andrequire("../theme")from the requiring file,require("@components/card")through thealiasesin a.luaurcbeside the script,require("@self/x")from the requiring file’s own directory, and a directory as itsinit.luau(a barerequire("lib/theme")resolves from the script’s directory). Modules stay inside the script’s directory, run once and return what they return; a change to any of them reloads the scripts, keeping state. A module’sui.stateandui.persistkeys are its own (two modules’ui.state("open")are two states), and so are the{name}s its text templates show;ui.sharedstays global, andui.use_stateis per component instance. App structure gives a layout for a large app, with its shared state in one module.
-- ui/components/card.luau
type CardProps = { title: string, tone: ("info" | "error")? }
return function(props: CardProps)
return ui.column { p = 12, rounded = 10, ui.text { props.title } }
end
-- app.luau, with .luaurc: { "languageMode": "strict", "aliases": { "components": "./ui/components" } }
local Card = require("@components/card")
return Card { title = "Saved" } -- `Card { titel = … }` is a type error in the editor
ui.state(key, initial): private to the script, kept across reloads.ui.shared(key, initial): shared with Rust and every script.ui.persist(key, initial): likeui.state, and saved across runs (see below).- State values:
nil, booleans, numbers, strings, lists, string-keyed tables and elements (ui.use_state(el),state:set(el): Rust sees them asValue::Element, opaque, equal only to themselves,nullin JSON). A value is an immutable snapshot:getandpeekreturn read-only tables (changing one is an error that names the methods below), and every change makes a new value that shares whatever it did not touch, so tracking stays cheap and a write of an equal value notifies nobody. - Handles:
get(tracked),peek,set(v),set_key(key, v)(one key; an index from 1 into a list; or a path,set_key({ "tabs", 3, "title" }, "x");nilremoves the key),patch({ a = 1, b = 2 })(a shallow merge),update(fn)(fngets a mutable copy: change it in place and return nothing, or return the new value),insert(i, v),push(v),remove(i),toggle,add(n),len,count(query, { case, limit })andfind(query, { case, limit })(a string’s matches,{ from, to }bytes from 1 and whether there were more thanlimit: searched where the string is held, so a long document isn’t copied into the script).set_key,patch,insert,pushandremovecopy only the path they change;updatecopies the whole value into Luau and back, so prefer the others for large state. ui.computed(fn): derived state, current whenever it is read, in a handler orui.batchtoo, right after a write it depends on.ui.effect(fn): runs again when what it reads changes.ui.batch(fn): one update for many writes.ui.sorted(list, { by, descending, filter }): a list state’s items in order, kept in order as rows are written (state:at(i)reads one).ui.form { fields, validate, submit, on_success }: a form’s fields checked as they are typed (shown once left or a submit tried), its submit in a task withpendingandfailure; a control takesfield = form.fields.emailfor its value, its error (heard by assistive technology) and Enter submitting (Forms).ui.resource(load, { initial }): data loaded in a task (loadmay awaitui.fetchorui.call), as statesdata,loading,errorandstatus, withreload; it loads again when a stateloadread before its first await changes, keeping only the last answer (Loading data).ui.every(ms, fn)callsfneverymsmilliseconds until it returnsfalse;ui.after(ms, fn)calls it once. Both return a function that stops them, and are owned like effects. They sleep between firings: no frames run while a timer waits (a clock showing “4m ago” ticks once a minute, not sixty times a second).ui.ref(): give a noderef = randr:get()(orr.element) is its element while it is mounted andnilwhen it is not; reading it in an effect or computed follows it, so an effect that needs the element runs again once it mounts (local el = field:get(); if el and wanted:get() then el:focus() end).r:peek()reads without following;r.mounted,r.countandr:elements()(every node sharing the ref) are tracked too.- Functions that build part of the interface (
eachrows,presencecontent, components) own what they create:ui.computedandui.effectmade while building live as long as the part they built. ui.component(function(props) … end)returns a constructor:Card { title = "…", child }(children also arrive asprops.children). Inside,ui.use_state(initial)is per instance, effects belong to the instance, andui.on_mount(fn)runs once it is mounted (outside a component, once the app’s interface is).ui.variants { base, variants, compound, defaults }gives a component its styles by variant, as shadcn’scva:variants = button { tone = props.tone, size = "sm" }on an element. Options chosen by name resolve into the element’s literal styles as it is built; chosen by a state or function, they restyle it in Rust when that changes, interaction styles included, without calling the script. The options are typed from the definition (tone = "loud"is an error), andVariantProps<typeof(button)>gives a component’s props them. See Design systems.
Saved state
ui.persist(key, initial) returns state that the next run of the app starts from. Each key is a JSON file (Value::to_json) in the host’s storage: ScriptHost::app_id("my-app") puts it in the platform’s data directory for the app (~/Library/Application Support/my-app/script-state on macOS, %APPDATA%\my-app\script-state on Windows, $XDG_DATA_HOME/my-app/script-state elsewhere; zgx_script::app_data_dir), and ScriptHost::storage(dir) puts it anywhere else. Without either, it is kept only until the app quits, with a warning.
local notes = ui.persist("notes", {})
notes:insert(1, { id = "n1", title = "Groceries" }) -- saved within 250 ms
A key is read from disk the first time a run asks for it, while the script loads; after that it lives in the host like ui.state, so a hot reload keeps the live value and reads nothing. Keys are the script’s own and a required module’s are its own, as with ui.state. Writes are batched: the first change starts a 250 ms timer, and then every value changed since becomes JSON once, on the main thread, and are written by a background thread to a temporary file renamed over the old one, so a crash leaves the old value or the new, never half a file. ScriptHost::save() writes what is pending and waits; dropping the host does too. A file that is not JSON starts the key from initial, with a warning.
Plugins
A plugin is a script that runs beside the app in a VM of its own and adds to the app’s slots: ui.slot(name, { context, from }) is where contributions go, and ui.contribute(name, node) adds one. A contributed component is an instance per slot, and gets the slot’s context as props.slot: one panel component, a panel per pane. from = "notes" shows only what the plugin notes contributes.
ScriptHost::plugins(dir) loads every script in a directory; set_plugins(vec![Plugin { name, entry, grants }]) sets the plugins an app manages itself (one that asks its user first), from the next reload on, read from the disk even in a bundled app and watched for edits. What a plugin may do is what the app grants it, and nothing else of the app’s is in reach:
| the app’s scripts | a plugin | |
|---|---|---|
its own ui.state, ui.persist, components and slots’ contributions | yes | yes, its own only (a plugin named as an app script still has its own saved state) |
host actions (ui.action, ui.invoke, ui.call) | every one | those expose_action(name, capability) exposes, and only with the capability when it names one |
shared state (ui.shared, {key} in a template) | every key | those expose_shared(key, capability) exposes, likewise |
ui.fetch, remote images | yes | with Capability::Fetch |
| the clipboard, file dialogs | yes | with Capability::Clipboard, Capability::Files |
terminals (ui.term) | yes | with Capability::Terminals |
web pages (ui.web, ui.webview) | yes | with Capability::Web: its own pages, with their own cookies and storage, not the app’s |
windows of its own (ui.window) | yes | with Capability::Windows (one could pose as the app’s, or the system’s asking for a password); never the app’s title bar (ui.window.titlebar_area, controls) |
| files: modules, themes, images | from its directory | from its directory or below, links followed (one pointing outside is outside) |
commands (ui.command) | yes | yes, as <plugin>/<id>; a shortcut the app (or an earlier plugin) has stays theirs |
A plugin’s files are source, always: Luau checks no bytecode it loads, so a plugin’s (or a module’s) bytecode is read as source and fails to parse, rather than run. Only an app’s bundle carries bytecode, its build’s.
Reaching for something it was not given is an error that names what it needs; a plugin that fails to load is left out, and the app and the other plugins load. Capability::Named(..) is the app’s own vocabulary for what expose_* asks. The terminal example’s plugins (examples/terminal/plugins.rs) are a whole system on top: a folder per plugin with a manifest saying what it needs, a sheet asking the user to allow it, and its panels opened from ⌘K.
Testing an app
zgx_script::testing::TestApp mounts a host headless, with the task executor and frame clock a window has, and drives it as a person would. Set up actions, the fetcher and shared state on the host before returning it; TestApp::wrapped provides what a window does, and TestApp::with_desktop a clipboard and file dialogs (testing::FakeDesktop) that answer as the test queues (desktop.pick([path]), desktop.save_to(path)), so open and save run as in a window.
use zgx_script::{ScriptHost, testing::TestApp};
#[test]
fn signing_in_greets_the_user() {
let mut app = TestApp::new(|runtime| {
let host = ScriptHost::new(runtime, "ui/app.luau");
host.async_action("sign_in", |_| async { Ok("Ada".into()) });
host
});
app.type_text("email", "ada@example.com");
app.click("sign-in");
assert!(app.wait_until(|app| app.text("greeting") == "Hello, Ada"));
let calls = app.calls();
app.frames(30);
assert_eq!(app.calls(), calls, "nothing runs while nothing changes");
}
It has click, type_text, key and key_with, frame and frames, settle (run what is ready), wait_until (for host calls, fetches and timers, up to five seconds), find, shown (found and not transparent: a closed modal’s kept content is not shown), bounds, text, texts, opacity, error and calls (calls into scripts: a hot path holds at zero). zgx-script’s own tests use it.
Shipping an app
In development a host reads its scripts from the disk and reloads them as they change (ScriptHost::new and watch). What ships carries them compiled into the binary instead: a build script bundles the directory, and the host runs from the bundle.
// build.rs (zgx-script as a build-dependency)
fn main() {
zgx_script::build::bundle("ui", "app").unwrap();
}
// the app
static UI: zgx_script::Bundle = zgx_script::include_bundle!("app");
let host = ScriptHost::bundled(&cx.ui.runtime, &UI, "app.luau");
bundle compiles every .luau file to Luau bytecode (a script that does not parse fails the build, with its file and line) and keeps .luaurc, theme .json files and images as they are; Rust sources and other dotfiles stay out. A bundled host reads nothing from the disk: require, @self, .luaurc aliases, ui.theme.load, ui.image and ScriptHost::plugins all resolve inside the bundle, and a module it does not have is an error, not a read elsewhere. The plugins an app sets (set_plugins) are its users’, read from the disk; watch on a bundled host watches only those.
Errors are for the developer: error_banner(true) shows each over the interface with where it happened, and is the default in debug builds. In release builds the banner is off: a failed load or a handler’s error leaves the last good interface running, and host.on_error(|message| ...) hears it, for the app’s log or crash reports. The terminal example builds either way: with --features terminal-bundle it runs from its bundle (ZGX_SCRIPTS=source reads the source tree instead), without it from the source tree, reloading as it is edited; scripts/zterm-app.sh packages the bundled build.
Warnings
Some mistakes are not errors but are probably wrong; each is reported once per call site, with its file and line, to stderr and to ScriptHost::warnings(). Durations are milliseconds everywhere (ui.tween, ui.loop, layout_duration, anime): a fractional duration under 10 (duration = 0.16, likely meant as seconds) and a non-looping one over a minute both warn. ui.every under 16 ms warns too: it calls the script more often than a display refreshes. A ui.each over more than 1,000 items warns: it builds every row, where ui.list builds only those in view. An anime.animate line that replaces its own unfinished animation again and again warns as well (what drives it changes faster than it plays).