Overview
Interfaces from Luau scripts: built once, lowered to retained views, hot reloaded with their state kept.
Coming from React? Coming from React maps its patterns (state, effects, context, lists, data loading, forms, modals) to these, with the performance rules.
zgx-script builds interfaces from Luau scripts. A script runs once per load and returns a description of components, state bindings and animations; Rust lowers it to ordinary retained views. Layout, drawing, state and animation all stay in Rust. The script is called again only for event handlers, components as they mount, and properties it explicitly computes with a function. Text templates ("Count: {count}") and every animation run without calling the script.
local count = ui.state("count", 0)
return ui.column {
p = 24, gap = 12, bg = "#172030",
ui.text { "Count: {count}", text_size = 18 },
ui.button { on_click = ui.add(count), px = 16, py = 10, "Increment" },
}
let host = ScriptHost::new(&cx.ui.runtime, "app.luau").plugins("plugins").app_id("my-app");
host.action("save", |arg| { /* Rust, called by ui.action("save", arg) */ });
let _ = host.reload(); // errors show over the window
cx.tasks.spawn(host.watch(Duration::from_millis(250))); // hot reload in development
cx.render(host.view());
Or run a script with no host of your own: cargo run -p zgx-run -- path/to/app/ (zgx-run app.luau, once installed with cargo install --path crates/zgx-run) opens it in a window and reloads it as its files change. A zgx.json beside the script sets the window (title, width, height, min_width, min_height, titlebar: "standard", "inset" or "hidden", glass) and app_id (where ui.persist keeps its data, and on Linux its windows’ desktop file ID, app_id.desktop, which docks match them to), and application_id (reverse-DNS: the system then hands the app the files and URLs it opens, ui.on_open); what follows the script on its command line is its ui.args, and the files among them open with it; ui.command entries are its menu bar and ui.window declarations its other windows. The mail example (examples/mail) runs this way.
State is Rust-owned and keyed, so a reload keeps it, as React’s Fast Refresh keeps a component’s state: a save shows new code with the values you had, not the ones your code starts from. So changing a state’s initial value (ui.state("messages", sample(4000)) to sample(100)) does not apply to a running app; change its key (ui.state("messages_v2", …)), which makes it a new state from its initial value, or restart. ui.persist keeps its value across launches the same way. Each script gets its own sandboxed VM (read-only globals, a heap limit, 1 GiB for the app’s own scripts, which hold its data, and 32 MiB for a plugin’s, its state’s values (which Rust holds) counted with its VM, and a time budget per call, 50 ms of the CPU time the script uses, so a busy machine does not fail it, and ten times that in a debug build, which runs scripts several times slower; it counts from a CPU-time reading up to 1 ms old, so a script may be stopped up to 1 ms short of its budget, never past it), which makes plugins safe to load next to the application. A host changes them with ScriptHost::limits; a script that reaches one says which. A failed reload keeps the last good interface running and shows the error, with file and line, until a good save.
The Luau reference has a page per element and API group: signatures, every option with its type and default, an example and the gotchas. App structure shows how to split a large app across modules that share its state.
Examples: hot_reload (an app and two plugins), frame_profiler (a profiler window with sample data, menus, popovers and tooltips), stream_luau / stream_rust (the same window in Luau and Rust, compared by examples/stream/measure.sh), anime_showcase (the animation API), anime_bench (per-frame engine cost), state_bench (what a state update costs), notes (an app that saves with ui.persist), mail (a mail client in modules: a virtualized list, a selectable message, a spellchecked compose sheet, menus, tooltips and a settings window; run it with zgx-run), chat (a chat client against a model served in memory: replies streamed as server-sent events into a virtualized transcript of markdown with code blocks, a composer that grows, conversations with a search, and shortcuts; cargo run -p zgx-run --example chat, zgx_run::run_hosting serving it), and in zgx-desktop terminal and zgx_monitor (multi-module apps whose panes split, resize and move by dragging; the monitor’s host samples processes and publishes them as shared state).
Comparison with Rust views
The streaming example (stream_luau against stream_rust, 60 tokens a second into a transcript) measured on macOS: streaming CPU within noise (2.6% each), about 2% more instructions for Luau, and 0.9 MiB more memory (a Luau VM and the script’s tree). Idle CPU is zero for both.