Repository navigation
Releases
zgx 0.1.10
A release for apps that run plugins: what a plugin may reach is now the host's to decide (ScriptHost::guard and its new GuardKinds, fetch_policy, reserve_prefix, decision controls, sealed regions, a top layer), and a host can ask where a plugin's additions show and forget its state as it is uninstalled. It also has lists that scroll with the view around them, a window's full-screen state in scripts, glass on Linux and in Windows' popups, and terminals that follow the theme.
Three things change for a plugin, each with its migration under Changed: Capability::Override("*") no longer reaches the app's modules, a plugin's ui.slot is named <plugin>.…, and its shortcuts need a modifier or a grant. The app's own scripts are as they were.
Published with it: zgx-cosmic-text 0.19.10 (a family's nearest face for a weight it lacks).
Apps from crates.io get upstream winit, not the copy patched in this repository, and a fix that lives only in that copy doesn't reach them. In this release that is two: glass blurring on Wayland compositors other than KWin, and set_theme on Windows holding through a system change. Entries that need the patch say so, earlier releases' too, and releasing lists all it does; getting started has the [patch.crates-io] lines that take the repository's.
Changed: what to check in an existing app
WindowOptionshas a new field,caption_height(zgx-desktop): aWindowOptionswritten out field by field stops compiling. Migrate: end it with..Default::default(), or setcaption_height: None(the default).Capability::Override("*")no longer reaches the app's modules (zgx-script): replacing a module runs as the app's own code, so it takes a grant that says so.Capability::OverrideElement(target)is forui.contribute { replace }andui.filter,Capability::OverrideModule("@app/…")forui.override, each with"*"for any. The olderOverridereads by what it names, soOverride("#x"),Override("*")for elements andOverride("@app/x")work as before. Migrate: a plugin givenOverride("*")that overrides modules needsOverrideModule("*"); inzgx.json,override-module:*besideoverride:*.- A plugin offers only slots of its own (zgx-script):
ui.slot(name)in a plugin's VM takes<plugin>.…, so a plugin can't pose as the app's slot or another's and take what is contributed there. Migrate: name a plugin's slot<plugin>.name, and have what contributes to it say so. The app's own scripts name theirs as before. - A plugin's shortcut has a modifier (zgx-script):
ui.command { key = "k" }from a plugin (no modifier, or Shift alone on a character) would take that key from every field, page and terminal, and is now an error; one a state or function says is dropped as it reads so, inScriptHost::dropped_shortcuts. Migrate: give it a modifier, or grant the pluginCapability::Named("zgx.command.bare_keys").
Added
- Decision controls no plugin reaches or covers (zgx-script):
ui.decision { key, … }holds controls drawn and hit over all a plugin opens, in the room it keeps in flow, scrolling with it.- A plugin can't make one, or get into one; replacing, hiding or disabling what declares one is refused and reported.
- While one is mounted, a plugin's popups, tooltips and context menus stay in the window, under it (
zgx::compose::PopupsInWindow). ScriptHost::decision_shown(key)says whether it is on screen, for a host's fallback in its top layer. In Rust,zgx::compose::hoist.
ScriptHost::guardcovers terminals, web pages and what else leaves the interface (zgx-script): newGuardKindsTerm(ui.term, by its command or the shell: refused, it raises and nothing starts),TermInput(a terminal's:input,:paste,:binding),Web(src,:load,:html,html, and each navigation the platform starts: a link, a form, a redirect, a page's script, history),Clipboard,Dialog,File,FolderandNotification. A refusedsrcleaves the page where it was; a refused navigation is cancelled and reported. What a page fetches once loaded, and in-pagehistorysteps, no platform shows:docs/plugins.mdsays so, and lists what isn't asked.ScriptHost::guardis asked of links, pictures, windows, kept values and a download's dialog (zgx-script, zgx): newGuardKindsOpenLink(a link in text opening, on a click or its Open Link item, through zgx's newplatform::LinkGuardservice),Picture(a remote picture, apart fromFetch),Window(ui.window: refused, it raises and nothing opens) andPersist(ui.persist: refused, it starts from what is saved and its writes are held in memory, so a preview in the app's VM looks as the app does and overwrites nothing), and a download'sd:ask()underDialog.ScriptHost::keep_held_statesaves what was held, when the person keeps what they previewed;ScriptHost::drop_held_stateor quitting lets it go. A refusedui.fetchrejects its promise rather than raising, so a top level isn't failed and what handles a failure says so.- A host's top layer (zgx):
View::top_layeris a layer filling the window, above every other layer, dialog and popover and live under them, for the host's own controls that no plugin may cover. A script'szcan't reach it. zgx inspect keytaps modifiers alone (zgx-script):zgx inspect key alt(orctrl+shift) holds them and lets them go, as a window reports a tap, so what watchesui.modifiers(an Alt-to-show menu bar) can be driven; it used to fail with "unknown key".- A window's glass blurs the desktop behind it on Linux (zgx-desktop): a window with
glassasks the Wayland compositor to blur what is behind it. It was only transparent before, so on KWin, which blurs only what asks, it was see-through. From crates.io that reaches KWin alone, through its own blur protocol (upstream winit'swith_blur). With the repository's winit (getting started) it goes through the standardext-background-effect-v1first, so Hyprland blurs too. How strong the blur is stays the compositor's (Hyprland:decoration:blur, and only with blur enabled there). - A list can scroll with the scroll view around it (zgx, zgx-script):
ui.list { …, virtualize = "ancestor" }(Rust:View::virtualize_in_ancestor) is as tall as all its rows and builds only those the scroll view around it shows, plus its overscan, again as that view scrolls or resizes. A panel that scrolls as a whole, its sections lists, builds about 20 rows of 302 instead of all of them. Withheightorheights: Tab and focus go through its rows, a row asked for (scroll_to_row) or found in the page scrolls that view to it, and a reload finds focus again on its row. Opt-in: a list's rows otherwise build as before. ScriptHost::contribution_bounds(plugin)(zgx-script, with zgx'sNodeHandle::window_bounds): where what a plugin added shows in the window now, a box for each thing mounted in a slot or before, after or in place of an element, through any transforms and zooms. What aui.filterchanges stays the app's own. Costs nothing without plugins.ScriptHost::forget_state(script)(zgx-script): for uninstalling a plugin,"plugin:<name>"forgets itsui.stateandui.persistvalues, what waits to be saved and its saved files, gone by the time it returns; a write already queued can't bring them back.- An app can police what its plugins reach over the network (zgx-script, zgx-run):
ScriptHost::fetch_policy(impl FetchPolicy)is asked of a request's URL, of every address its host resolves to, and of its headers, with the plugin's name as the caller (Nonefor the app). zgx-run asks it on every redirect, before connecting to any address (so a name that later resolves to this device or the LAN is refused too), and forui.fetch, remote images andui.socketalike. A plugin's refused or failed remote image is its own: the app showing the same URL fetches it at once, not after that plugin's backoff. A plugin never takes a proxy from the environment, and never sets Host, Connection, Transfer-Encoding, TE, Upgrade orProxy-*, with or without a policy. A host with a transport of its own reads the same withBody::caller,Body::fetch_policy,Socket::callerandSocket::fetch_policy. ScriptHost::reserve_prefix(prefix)(zgx-script): keepsprefixandprefix.…for the app's own scripts: no plugin, whatever it is named or granted, offers aui.slotor declares aui.commandidso named.ScriptHost::guard(|kind, name| …)(zgx-script, withGuardKind): asked before the app's own scripts reach a host action, async action or function (by name), or aui.fetch, remote picture orui.socket(by URL), each time; anErrstops it, its text the script's error. For previewing someone's template in the app's VM. A host that sets none reads one thread-local flag at each.- Sealed regions (zgx-script):
ui.sealed { … }is a region no plugin contributes into, replaces, filters, targets or draws over (a Settings page). In it,ui.plugin_page { plugin = name }shows what that plugin alone givesui.contribute("plugin_page", …), cut to its box, portals included (zgx::compose::confined). - Terminals take their colours from the theme (zgx, zgx-terminal, zgx-desktop): the tokens
terminal.foreground,terminal.background,terminal.cursor,terminal.cursor_text,terminal.selection_background,terminal.selection_foregroundandterminal.ansi.0toterminal.ansi.15colour a terminal where the theme sets them, and follow a theme switch, the system's appearance or atheme =scoped over the terminal live, scrollback included, without ...
zgx 0.1.9
A release of fixes before work pauses, from bugs Zeron's apps found and from the remaining bugs on Linux, Windows and macOS; the few additions are what those fixes needed (a host-loaded locale catalog, opt-in partial hot reloads, the window's size in plugins). Rust and Luau code builds as before, but for a struct literal of zgx's trace::Frame (it has new fields), below with what to change. Every published 0.1.8 crate builds against this release. Forks published with it: zgx-cosmic-text 0.19.9 (glyphs drawn through Core Text where only hvgl has their shapes) and zgx-alacritty-terminal 0.26.1 (a Windows program's last output read after it exits). Behaviours change too, each below with what to check.
Changed: what to check in an existing app
trace::Framehas new fields,eventsandlayout(zgx): the input events a traced frame took, and what layout measured afresh in it (zgx inspect frames). AFramewritten out field by field stops compiling. Migrate: end it with..Default::default().- A picture that can't be shown is told, never a script error (zgx-script): a
ui.image,ui.svgorbg_imagewhose file is gone or isn't a picture draws nothing, shows the image'sfallback(now for local files too, not only remote ones) and is told to its newon_error(why), else warned of once, with the script line naming it. It was a script error, which in a gallery stopped every window's reloads, though a file can go at any time.on_load({ w, h })tells a picture's size once known (a file's from its header, before it draws; a remote one's as it arrives), andui.image_size(src)reads it from the header alone, nothing decoded, so a design system can hold a picture's place at its final size. - A style re-run with the same result no longer resets paint set elsewhere (zgx): as with an element only changed props touch, an unrelated state change that re-runs a node's reactive style (or a script's style function) to the same result writes nothing, so paint an
anime.animate, a script or a direct scene write set on the node (its effects, transform, clip) stays until the style's own value for that property changes; then the style's new value wins. Before, such a re-run restored the node's own paint, undoing what was set since (as a style changing only a box's size already did not). - WebView2's browser keys are off (zgx-desktop's
webview2): Ctrl+F, F3, Ctrl+P, Ctrl+R, F5, zoom, Alt+arrows and F12 in a page reach the page, then the app's bindings when the page leaves them alone, as on macOS and Linux, instead of WebView2's find bar, print preview, reload and DevTools. Editing keys stay the page's. Find withWebView::find; DevTools withWebView::open_devtools.
Added
- A screen reader moving to a node in a scroll view scrolls it in (zgx-desktop, zgx): nodes in a scroll view offer AccessKit's
ScrollIntoView(macOSAXScrollToVisible, UIA's and AT-SPI's scroll-into-view), and the tree with the node's new place goes out at once, not paced as geometry is.Ui::scroll_into_view(node)does the same for an app. - A shortcut recorder takes every key (zgx, zgx-script, zgx-desktop):
capture_keys = trueon a node (View::capture_keys,InputDispatcher::set_capture_keys) sends every key to what is focused while focus is in it: no key binding takes one, and on macOS the menu bar's key equivalents (⌘Q, ⌘C, the app's commands) don't fire, the key going to zgx's view. A settings page records ⌘J as ⌘J instead of running its command. As focus leaves, shortcuts work again; a host asks withInputDispatcher::captures_keys, andbinds_keyanswers no meanwhile. - A command's shortcut can follow a state (zgx-script):
ui.command { key = state }orkey = function() … endpublishes the commands again whenever what it reads changes, so a shortcuts setting rebinds a command at once, menus included, with no reload. While a key follows a state, a key an earlier command already holds stays with it and the later command is published without it, reported inScriptHost::dropped_shortcuts(itspluginempty for the app's own), so a rebinding never gives the menu bar a key twice.ui.shortcut_owner(keys)names the command holding a key now. - Drop shadows (zgx, zgx-gpu, zgx-script):
Styled::drop_shadow(x, y, blur, color)(Scene::set_drop_shadow,DropShadow), Luaudrop_shadowwritten asshadowis: a shadow of what a node and its subtree paint, following their alpha rather than the box (CSS'sfilter: drop-shadow): an icon's, a cut-out's, a rounded card's. The node paints as a cached layer; its shadow is a second slot of the effect atlas, blurred by the same passes asfilter_blurand tinted there, then composited under the layer: a shadow moved or faded composites again without repainting the layer, and one blurred anew re-runs the passes only. Nodes without one cost nothing. Not drawn by the Direct3D 11 proof, as no layer effect is. - Focus moving into a list scrolls it there, clear of what floats over it (zgx, zgx-script): Tab, Shift+Tab or
el:focus()onto a control in aui.list's row (a transcript's tool row built beyond the rows in view) scrolls the list to it, the offset state following, as a plain scroll view already did.scroll_padding(ui.scroll,ui.list,View::scroll_padding; a number or{ top, bottom, left, right }) keeps that much clear inside its edges, so a focused row doesn't land under a composer or a title bar.el:focus({ scroll = false })(NodeHandle::focus_with,InputDispatcher::focus_in_place) leaves the views where they are. - A GIF in
ui.imageplays (zgx, zgx-gpu): an image view whose source gives an animation's still (ImageData::animated, which zgx-gpu now decodes a GIF to) plays its frames asanimated_imagedoes, paused off screen, hidden or with the window unseen, its last frame kept once a finite one ends. The playback is set up the first time a source gives one, so a view of still pictures costs nothing more; a reactivesrcthat changes to another GIF plays that one, and to a still stops. A GIF whose frames pass 4 MiB decoded streams them (Animation::streamed, zgx-gpu'sGIF_KEPT): its file and a frame or two held, however long, all of it given back as it goes; a smaller one keeps its frames and costs nothing to play. Under the system's reduced motion an animation (animated_image's too) shows its first frame, still. - A modifier pressed alone is heard:
Ui::modifiers()andui.modifiers(zgx, zgx-desktop, zgx-script): the modifier keys held now, as a state that changes as one goes down or up, a modifier alone included (Command held to show ⌘1–9 hints, which no key event reports). Luau readsui.modifiers.shift,control,altandmeta, each tracked. All are up as the window loses focus, so a ⌘-Tab away leaves none held. The state is made the first time something asks for it; until then the host writes nothing. ScriptHost::i18n(dir, source)loads a catalog with no script (zgx-script): a host loads the locale files in a folder of its choosing before any script runs, andui.tandui.localework in the app's scripts and its plugins' with noui.i18ncall. A hot-reloading host now watches every catalog's locale files, so an edit to one shows in place without reloading the scripts, wherever the folder is. A host that preloaded its catalog through a one-line plugin no longer needs that plugin, which made every hot reload a full one.zgx inspect framesmeasures an interaction as a whole (zgx-script, zgx-desktop):until(a mark or a frame) ends the frames read, assincestarts them, andsummaryanswers what they cost together: their count and pacing, the work's median, p90, p95, p99 and most, the input events they took and the work per event (a drag's moves), and each phase's spread. Each frame says the input events it took (events). A tapereplayplays can move the pointer too (ms pointer x y down|moved|up, from the target's centre), so a scripted drag lands a move a frame through the window's own input path, and its answer gives the same percentiles. The CLI reads a command's own fields given askey=valuewords (frames n=300,mark name=drag) and refuses a number field given anything else, where it fell back to the default.zgx inspect framessays where layout's time went (zgx, zgx-script): each traced frame counts the nodes layout measured afresh (not taken from a measurement kept from before) and their time, and names the costliest by their own time, their children's apart, as the script made them (layout: 14 measured afresh, 5120 µs; costliest: @88 #chat (column screens/chat.luau:41) …;layoutin its JSON). An interaction'ssummaryadds them up by node. While nothing traces, a fresh measure pays one relaxed load for it.scroll_paddingfollows a state (zgx, zgx-script):scroll_paddingonui.listandui.scrolltakes a state or a function as well as a number or{ top, bottom, left, right }, read again as what it reads changes (View::scroll_padding_with), so a composer growing to more lines keeps a focused row clear of it, rather than the height it had as the list was made.- A long reply landing whole is parsed over frames (zgx-script):
ui.markdown(text, { key, lazy = true })(orlazy = bytes; 4 KB by default) answers a long text arriving at once (not by appends) with one entry a block at once, those not parsed yet{ t = "pending", hint, lines, from, to }, and parses them over the next frames, the end first, a block asked for withfirstbefore the others. Blocks keep their index and, parsed, their table; a computed around the call runs again as they come. The frame showing a 25 KB, 385-block reply parses it in 0.25 ms instead of 0.94 ms. Streaming appends and short texts are as before. Rust hosts callIncremental::set_text_lazyandparse_pending. - Nine-slice images (zgx, zgx-gpu, zgx-script): `Styled::i...
zgx 0.1.8
A fix for 0.1.7, which drew every Chinese character on macOS 26 as blank space: a font face whose shapes zgx can't draw (macOS 26's PingFang keeps them only in Apple's hvgl table) is now passed by in fallback, and Chinese takes Heiti SC or TC. Rust and Luau code builds as before, but for a struct literal of zgx's WebViewOptions or WebViewState (each has a new field), each below with what to change. Every published 0.1.7 crate builds against this release. Forks published with it: zgx-cosmic-text 0.19.8 and zgx-mlua 0.12.7. Behaviours change too, each below with what to check.
Changed: what to check in an existing app
WebViewOptionshas a new field,accelerated(zgx): aWebViewOptionswritten out field by field stops compiling. Migrate: end it with..Default::default(), or setaccelerated: true(the default).WebViewStatehas a new field,full_screen(zgx): aWebViewStatewritten out field by field stops compiling. Migrate: end it with..Default::default(), or setfull_screen: false.- A second
mountin a test is the next launch (zgx-script): a test's apps inzgx testshare a storage directory of the test's own, each saved before the next starts, so a secondmount()finds what the first kept (ui.persist, folders kept withui.open_folder { remember }), as a relaunch would. Each test starts with nothing kept. A headless app'sui.persistkept state in memory only. - A web page on Linux saves no downloads (zgx-desktop's
webkitgtk): adownloadlink or an attachment is cancelled as it starts, as on macOS and Windows, where web views save nothing without a handler of the app's. WebKitGTK saved them into the user's downloads folder unasked. - A test names an item's window by its key (zgx-script):
app:window(key)inzgx test, andTestApp::window, take the key of an item ofui.windows(itsidfield, orkey(item)), whose window's own id a test can't know. Aui.window's id still names it, and a key two lists share fails, saying so.
Added
- Clear coats in 3D scenes (zgx-3d):
World::set_clearcoat(id, Some(Clearcoat::new(1.).roughness(0.1)))lacquers a mesh, as car paint, varnish or a glaze: a thin coat of glass reflects the lights and surroundings over it, smooth or rough, and what is under it (light through glass included) shows the less the more the coat reflects. glTF models bring theirs:KHR_materials_clearcoat's factor and roughness, and its textures (Map::Clearcoat,Map::ClearcoatRoughness,Map::ClearcoatNormal: how much coat, how rough, which way it faces, the last its own and not the surface's). A mesh with one draws with a pipeline of its own; a world with none binds nothing new. - Container queries (zgx, zgx-script):
container = trueon a box andqueries = { { min_width = 900, style = { … } } }on what's inside give styles that follow the box's content size, as CSS's@containerrules, in the frame layout changes it and without running the script; a resize that crosses no bound restyles nothing. As in CSS, a container's content doesn't size its width (a debug build reports one that's 0 wide). In Rust,View::containerandView::when_container(Query::new().min_w(900.), …), andScene::set_contained. - Targets by class in tests and inspect (zgx-script):
.tool,.tool.primaryand chains such as#bar >> .toolfind elements by theirclassinzgx test,zgx inspectand MCP, as plugin targets do: one selector language. - Gradient masks (zgx, zgx-script):
Styled::mask_gradient(Gradient)(Scene::set_mask_gradient), Luaumask_gradientandmask_radialwritten asgradientandradialare: a node and what it holds show through a linear or radial gradient's alpha over its box, at whatever size it's laid out (CSS'smask-imagewith a gradient), for a list fading out at its bottom or a vignette. The mask's image is made from the gradient at the box's size when it's laid out, and again when the box resizes; it then draws as an image mask does. - Gradient text (zgx, zgx-gpu, zgx-script):
Styled::text_gradient(Gradient)(Scene::set_text_gradient), Luautext_gradientandtext_radial: a text's glyphs filled with a linear or radial gradient over its box (CSS'sbackground-clip: text). The text paints into a cached layer of its own, where its glyphs draw, the gradient is kept where they cover (a source-in blend) and its selection, marks and shadows draw behind them (destination-over); selected glyphs then take the selected-text colour. The same shader as every quad, two more blend states (wgpu and Direct3D 11). Glyphs rasterize at the layer's scale, and the gradient's colours are made again only when the box resizes, so a counter re-uploads nothing but glyphs. Text without a gradient is no layer. - Devtools reach every window (zgx-script, zgx-run):
zgx inspect windowslists the app's windows (the main one,ui.window's andui.windows's), each with its id, size, title and popups open. Any command takeswindow(--windowon the CLI,windowon every MCP tool,zgx_windowsto list) and answers for that window: an id, or an item's key inui.windows. zgx-run registers each window it opens while devtools run (inspect::register_window), and forgets it when it closes. - Plugins add beside a branch's root (zgx-script):
beforeandafterat the root of aui.show's orui.switch's branch, or of a component, which matched nothing, are siblings in the parent's flow that come and go with the branch and read what the root provides, live. At the app's root or a window's root, which can't have siblings, they are an error inScriptHost::contributionsand to the plugin. - What each plugin contribution does now (zgx-script):
ScriptHost::contributions()lists eachui.contribute { target }andui.filterwith its plugin, target, place, how many elements it applies at now (0 when its target matches nothing shown) and its latest error.zgx inspect contributionsand the MCP toolzgx_contributionsgive the same, as text and JSON. - A plugin command's dropped shortcut says so (zgx-script): when a plugin's command declares a shortcut the app's commands (or an earlier plugin's) hold, spelled the same or not,
ScriptHost::dropped_shortcutsrecords the key, why ("taken by the app") and the command that holds it, andScriptHost::contributions(zgx inspect contributions) lists it as placeshortcut, for the app to tell the plugin's author. - Where a plugin can put something and what it can read there (zgx-script):
ScriptHost::elementslists the app's ids, classes and hooks grouped by each element thatprovides context, with a selector that reaches each group and the context names it provides and inherits. A list's repeated rows are one group with a count, private elements show nothing inside, and names the source declares that aren't showing are listedmounted: false.zgx inspect elementsand the MCP toolzgx_elementsgive it as text and JSON. - A host lends a package to plugins (zgx-script):
Capability::Package("ds")lends packagedsto a plugin, andScriptHost::grant_all_pluginsgrants a capability to every plugin, those loaded later too. zgx.json allows"package:<name>". The plugin requires@ds/…into its own VM, with module state of its own. A lent module requires only inside lent packages, never@app/…, and an error names the missing grant. What it calls is gated by the plugin's grants. An edit to the package reloads its plugins, andcontributions()lists what each plugin was lent.zgx checkresolves a plugin's lent packages, andzgx typesadds their aliases to the plugin's.luaurc. - Plugins add beside a presence's content (zgx, zgx-script): what a plugin puts before or after a
ui.presence's content root enters with it, and as the content leaves it stays where it lay until the exit animation ends, then goes in the same frame. A contents node whose last child lays out in its place (display_contents) now lays out flat too, with what it holds before that child kept over its place (#1275). - Plugins add beside a list's rows (zgx-script):
ui.contribute { target, before | after | replace }andui.filterat the root of aui.eachorui.listrow, which drew nothing, apply a row each: what is added lays out as the list's own items, keyed with its row (it follows a reorder and goes with a removal), and reads what the row's root provides, live. - Plugin targets by class and inside other elements (zgx-script):
ui.contribute { target }andui.filtertake.class(every element with it) and descendant chains,#sidebar >> .session-row, in the selector syntax tests use. A target matching several elements contributes at each, and a chain only where its ancestors hold, including inside a list's rows.zgx lintwarns of an id, hook or class in a target, a chain's parts too, that no element of the app declares; under strict types an app's classes are targets (HookTarget) and a chain is any text (HookChain). - Text streams into an editor where it ends (zgx, zgx-script):
TextEditor::apply_external(range, text)andappend_external(text)take an edit made outside the editor (a model's reply, a collaborator) asset_texttakes the document it makes, same undo and redo, comparing only the edit; the caret at the end follows the stream, and a selection keeps what it selected.- An editor bound to a long text follows the edits the text keeps (
zgx::text::Textremembers its last 16), applying each where it is. - A
\rand a\nstreamed in different tokens make one line break, not two. - Luau's
state:append(text)(andappend(path, text)) adds to a long string in place, keeping the edit for a bound editor, and a string growing past 64 KiB becomes one;zgx lintflagsstate:set(state:get() .. token)(append-concat).
- Text says the language it is in with
lang(zgx, ...
zgx 0.1.7
A patch: Rust and Luau code builds as before, but for a struct literal of PaintExtras or of zgx-desktop's WindowOptions (each has new fields) and code naming switches::ALL's length, each below with what to change. Every published 0.1.6 crate builds against this release. zgx-terminal is new: terminals on every platform, through zgx-desktop's terminal feature. Forks published with it: zgx-alacritty-terminal 0.26.0 and zgx-zune-jpeg 0.5.15 (new), zgx-cosmic-text 0.19.7 and zgx-mlua 0.12.6. Behaviours change too, each below with what to check.
Changed: what to check in an existing app
- A covered window stops drawing on Windows (zgx-desktop): as on macOS and X11, a window nothing of which can be seen, or on another virtual desktop, does no drawing, and
ui.window.shownis false there; on the way its menus and popovers close, as they do when it is minimized. A window that may show through (translucent, click-through, acrylic) never counts as covering it. Automated tests that need a covered window to go on drawing setZGX_RENDER_WHEN_COVERED=1. - A selection is one rounded shape (zgx): text selections, in selectable text and editors, draw lines that touch as one continuous shape, with rounded outer corners and curved inner ones, even in a translucent colour and at fractional scales.
$selection.radiusis now 3, not 0; set it to 0 for plain rectangles. An empty line in a selection shows as a sliver. An editor's selection is one node carrying a highlight, not a rectangle a line, so code that walked an editor's nodes for them finds it throughScene::text_highlight. zgx inspectandzgx testact only on what shows (zgx-script): a closed popover's, menu's or modal's content stays in the scene, unseen, andclick,hover,right-click,drag,scroll,snapshotandwaittook it as if it showed, landing on whatever was under it. They now fail withdoesn't show … (open what holds it first);findandtreestill list it, markedhidden. A test that clicked something closed now fails where it used to pass.- zgx-terminal's screen model is zgx's fork,
zgx-alacritty-terminal(zgx-terminal):alacritty_terminal0.26.0 without its comparisons ofusizeandi32with its grid indices from the primitive's side. Those impls reach every crate linking it, where an inferred comparison (range.contains(i)withi: &&usize) stopped compiling: an app turning on zgx-desktop'sterminalfeature would have found code of its own broken. Its defaultserdefeature is off. - A value the app sets on a
ui.inputorui.textarealeaves nothing to undo (zgx-script), as setting an HTML field's value does: ⌘Z after showing another note could write the old note over it, andon_changewould save that. The field's own edits still undo. A value answering one keeps the history: withvalue = function, the function's next change afteron_changeoron_commit, however late; withvalue = state, a different value within 100 ms of the edit. Rust's bindings keep their history (TextEditor::load_nextasks for this). - An effect's run owns what it makes (zgx, zgx-script): a
ui.on_cleanup,ui.afterorui.everymade in aui.effectnow goes before the effect runs again, and with the effect, as the docs said and a React effect's cleanup does. Before, a run's cleanups and timers went to whatever was being built at the time, or to the script until it reloaded: content aui.presenceswapped out kept its effects' cleanups, and an effect's cleanups didn't run between its runs. Aui.computedrun owns what it makes until its next run (start timers in an effect or a handler).Runtime::ownandRuntime::runninglet a host give a resource to the running effect. - Edge fades ease in (zgx-gpu):
fade/fade_sides(Styled::fade_edges) bands follow smoothstep rather thant².t²reached full opacity still rising, and the band's inner end showed as a line; smoothstep is level at both ends. Every fade looks a little different: content stays nearer full opacity further into the band and falls more steeply in its middle. Before and after:docs/results/fade-smoothstep-2026-10-31/. - The renderer going on with less stays out of release banners (zgx, zgx-desktop, zgx-script): a layer drawn coarser, an image softer, a glyph left out, a frame skipped after a GPU error or a layout that didn't settle showed "Something went wrong", sometimes in another window's banner. They are logged once as
degraded:, in their own window's banner in debug builds only.zgx::issues::IssueKind,on_issue_kindandreport_kindtell them apart;ScriptHost::on_issueandissues()give apps andzgx inspect errorsthe kind. An app'son_errorstill hears them. - Pictures keep their files, not their pixels (zgx, zgx-gpu, zgx-script): a script's
srcimage is read for its size alone, decoded off the main thread when drawn, shrunk to the size it shows, and its pixels given back once uploaded (1.5 s on). A page of eight large photos: footprint 662 → 294 MB. A picture over 256 by 256 pixels shows a frame later than before; one up to that size (an icon) decodes as it is first drawn, in that frame, anddecode = "sync"(HTML'sdecoding,ImageData::set_decoding) makes any picture wait rather than pop in. The image cache's budget counts files' bytes now;ImageData::pixelsstill gives all the pixels (decoded once and kept), andpixels_at,keeps_file,bytes,set_decoding,zgx::image::Decodingandzgx::image::release_decodedare new. monospaceis Menlo on macOS (zgx-gpu): every app's monospaced text now has a face of its own on macOS, as on Windows (Cascadia Mono or Consolas) and Linux. cosmic-text named Noto Sans Mono, which macOS lacks, so each word took the fixed-width face with most of its characters; Latin text already came out in Menlo. A word mixing Latin with characters Menlo lacks (Chinese, Japanese, Hebrew) now takes its Latin letters from Menlo and the rest from another face, where it could take all of them from one.zgx_3d::POINT_LIGHTSis 8 (zgx-3d, #1123), the point lights a frame shades (the nearest the camera's target); it was 4. Its spelling is the same, but an array sized by it now holds eight, and a scene with five to eight point lights now shades them all. Migrate: size by the constant, not by 4.zgx_script::Windowanddriver::Linekeep 0.1.6's fields (zgx-script): code building them with a struct literal compiles as before. A window'sunder_pointer,visibleand ownstateare read withScriptHost::window_extras(id)(aWindowExtras,non_exhaustivewithDefault, besidewindow_size_limits), and whether a tree line's node shows withdriver::tree_lines, whoseTreeLineholds theLineand itshiddenflag;treeandtree_textare as in 0.1.6.TabsOptionskeeps the fields 0.1.6 published (zgx-components):reorderableandon_close, as published zgx-script 0.1.6 builds it by literal. What tabs gained since (keep,group,on_move,on_tear_off) is inTabsMore, made fromDefaultand its fields set, and passed withtabs_with(label, items, selected, options, more, panel).tabsis as it was.- A tab can stay home when torn off (zgx-components, zgx-script):
on_tear_offreturns whether it takes the tab.falsekeeps it initems, its preview sliding home, as an app's "All" tab needs; Luau's returning nothing still tears it off.TabsMore::on_tear_off'sTearOffTabnow returns abool. - A terminal is a document named Terminal to assistive technology (zgx-terminal): its rows were children of an unnamed container that AT-SPI, UIA and AX drop, so they read as the window's own lines. They now sit in a document named Terminal (AT-SPI
document frame, macOSAXDocument). - Screen readers hear a terminal's new output, not each keystroke's line (zgx-terminal): an engine-drawn terminal's rows are text labels (they were images), and new output is a live region of its own saying the lines output brought in or what a line gained; before, the whole terminal was the live region and a reader re-read the prompt's line with every character the shell echoed. Checked with Orca on Linux and the AX tree on macOS.
- A test's clicks are counted as a person's (zgx-script):
zgx test's andTestApp's presses come with the keys held, as a window's do, soon_press'sclicksand a double click count in a test as they do in a window; separateapp:clickcalls in a row still count as separate clicks (InputDispatcher::forget_clicks). - A test or devtools action says what is in its way (zgx-script):
click,double_click,type,fill,hoveranddraginzgx test, andzgx inspect's actions, fail on a target outside its scroll's clip naming the clip ("scroll it into view first"), and on one under another node naming that node and where, rather than acting on whatever was there. Input'sInputDispatcher::pointer_targetis public: what a press at a point lands on. - A field with nothing to undo hands ⌘Z on (zgx), and ⌘⇧Z or Ctrl+Y with nothing to redo, to the app's commands, as AppKit's responder chain does: a
ui.command { key = "cmd+z" }reaches the app's own undo from inside a field withoutreserved = true. A field with a step still takes the key. WindowOptionshas new fields,max_sizeandvisible(zgx-desktop): aWindowOptionswritten out field by field stops compiling. Migrate: end it with..Default::default(), or setmax_size: Noneandvisible: true.- Wheels and drags in a zoomed box move it as far as the hand (zgx, zgx-script): a scroll view's offset,
on_wheel'sdxanddy, andanime.createDraggable's position took the window's points, so underzoom = 0.5content moved half as far as the wheel or pointer did. They are in the box's own points now. A drag'sgrab_x,grab_yare from where the source shows. An app that s...
zgx 0.1.6
A patch: Rust and Luau code builds as before (every API change since 0.1.5 is an addition, and zgx-desktop and zgx-gpu 0.1.5 build against this zgx). zgx-gpu and zgx-desktop now need zgx-cosmic-text 0.19.3, published with it. One behaviour changes, below with what to check: Windows input methods go through the Text Services Framework by default.
Changed: what to check in an existing app
- Windows: input methods go through the Text Services Framework by default, not IMM32. Editors give input methods and text services (the emoji panel, dictation, the touch keyboard) a text store: they read the text around the caret, place candidates at the clause they convert, reconvert committed text in place and follow the editor's input mode, and each clause is underlined. To keep IMM32, an app calls
Application::text_services(false), and a user setsZGX_TSF=0(ZGX_TSF=1forces the framework on); a window where the framework does not start uses IMM32 (windows).
Added
- Emission and occlusion maps in 3D scenes:
Map::Emissioncolours the light a mesh gives off (multiplying its material's emission),Map::Occlusiondims the ambient light reaching each point (glTF's occlusion, not the sun's or a bulb's). glTF models bring theirs, andKHR_materials_emissive_strength. A scene without textures still binds none and costs what it did; DamagedHelmet with all five maps: 25 driver blocks, as with three. - Routing (zgx-script):
ui.router { routes, scheme }keeps where an app is: named routes with:params(navigateby name,openby path), back and forward with the mouse's buttons and Alt+←/→ (⌘[ and ⌘] on macOS), deep links by scheme, androuter.viewshowing each route. A modal, popover or menu can be controlled (open = function,on_close), so a modal route opens with its route and goes back as it closes (routing). - Reorderable lists (zgx-script):
ui.each(list, row, { key, reorder = function(from, to) end })makes rows the person drags into a new order (above or below a row, a line showing where) or moves with Alt+↑/↓, each a Tab stop told how to move and a keyboard move heard ("Moved Inbox to position 2 of 5"); it works withanimate.TestApp::dragandTestApp::scrolldrive drags and scrolling in tests (dynamic). - Lists that load as they scroll, and debounced states (zgx, zgx-script):
ui.list { on_end = fn, end_distance = 200 }(View::on_endin Rust) callsfnas the list's end comes in reach, once each time and again once its rows grow, filling a short view by itself, with no script running while it scrolls;ui.paged(load(cursor) -> { items, next })givesitems,loading,status,done,error(aUiError),more()andreload(), keyed likeui.resourceand cancelling a load superseded;ui.debounced(state, ms)followsstateonce it has settled, on one timer (state). A script node is also 16 bytes smaller (a list's rarely set options are boxed). - Handlers that await, and optimistic writes (zgx-script):
ui.async(f)is a handler runningfas a task each call (a save on a click, a toast's Undo), cancelled as its owner goes, an uncaught error reported once.ui.optimistic(state, next, save)writesnextnow, keeps it ifsavesucceeds and puts back what it replaced if not, returning the error; overlapping calls never revert over a newer write (state). - A 3D texture decoded from a file keeps the file, not its pixels:
Texture::decodeholds the PNG or JPEG and its decoded pixels only until a renderer has uploaded them, decoding again only if another upload needs them (a lost device, another window's renderer). Uploads share the decoder's pixels and build mipmaps without copying the full-size level. DamagedHelmet's live heap: 64.8 → 17.2 MB. - A failure is a typed error, not only text (zgx-script):
ui.try's error, a promise'serror,:after'son_errorand what:await()raises are aUiErrorwithmessage,kind("raised","action","network","cancelled","http","io"), an HTTP failure'sstatusandbody, and a raised value kept asvalue.ui.fetch(url, { raise = true })rejects a status outside 2xx as an"http"error. It is still text:tostring,..,{err}, string methods, a state set to it, a toast'stitle/bodyand props read its message. Code comparing it with==to a string, or checkingtype(err) == "string", should readerr.messageorerr.kind; under strict types, a state of strings is set toerr.message(state). - Forms filled from what is loaded (zgx-script):
ui.form { values = state }fills a form from a loaded record and follows it, a field the person has edited keeping what they typed; a form and its fields havedirty;submitandon_successare handed the form. Checkboxes, sliders, steppers, segmented controls and selects can be controlled as React's are (value = function,on_change).response.jsonis typedanyandform.resettakes any record.TestApp::fill(id, text)replaces a field's text. The settings example shows them together (forms). - Decoding a PNG or JPEG holds about the image once, not twice:
zgx_gpu::assets::decode_imagedecodes into the image's final buffer, widening RGB and grey to RGBA in place, rather than into a buffer the image was then copied out of. A decode's peak: PNG 2.00 → 1.09 images, JPEG 2.00 → 1.36 (zune-jpeg's own colour planes). Forui.imageand 3D textures alike; under the system allocator, fewer freed large regions kept (a 3D model's three 2048 textures: 35.6 → 23.6 MB). - Normal and metal-roughness maps in 3D scenes:
World::set_map(id, Map::Normal | Map::MetalRoughness, texture)(zgx-3d;Map::BaseColourisset_texture's). A normal map tilts a surface by its texels (glTF's convention: +x along u, +y up the texture), its frame taken from the screen's derivatives, so meshes need no tangents. A metal-roughness map scales the material's factors (blue metal, green roughness). glTF models bring theirs, in Rust and inui.model. A scene without textures still binds none and costs what it did. - Textures in 3D scenes:
Texture(zgx-3d), made from RGBA8 pixels or a PNG or JPEG, andWorld::set_texture(id, texture)colour a mesh across its texture coordinates, mipmapped and filtered. glTF models bring their base colour textures, in Rust and inui.model. A scene without textures binds none and costs what it did. GPU-view painters getzgx_gpu::viewport::texture_with_levels: a texture written by the CPU in place on a Mac with unified memory, never copied. A copy into a texture would keep some 160 MB of Apple's driver memory while copies come (3D scenes). - Localized messages (
zgx::i18n): ICU MessageFormat messages from JSON locale files, the locale negotiated from what the app asks or the system's preferred languages, and each message followed, so a locale switch or an edited file updates only the texts that changed. Plurals and numbers come behind thei18nfeature, dates behindi18n-dates, both off by default (on in zgx-run). In scripts,ui.i18n { dir = "locales", source = "en" }loads a directory of locale files (a bundle ships them),ui.t(key, args)is a message as text or a label, andui.localereads and sets the locale. Every widget's label takes a message or a state and follows it: tabs, selects, modals, menus and the other components name their parts in the current locale. - A window's glass and light or dark change while it shows:
WindowHandle::set_glass(Some(glass))replaces its glass (macOS's effect view behind the content, Windows 11's system backdrop) andset_glass(None)removes it.WindowHandle::set_theme(Some(appearance))shows it light or dark whatever the system shows, title bar included, andNonefollows the system again. Both were fixed when the window was made. - Theme tokens as typed fields in scripts:
ui.tokens.notes.washis"$notes.wash", built from the applied theme, so an editor completes an app's tokens and luau-lsp checks them.zgx lint'sunknown-tokenrule names a token nothing declares (a misspelled"$app.crad"read as transparent before). - A key binding can name a physical key:
KeyBinding::code("Numpad1", modifiers, action)matches the key with that physical code (the DOM'sKeyboardEvent.code) whatever the layout makes it, so the keypad's 1 is apart from the digit row's. - A select's options follow a state (
ui.select { options = state }, Rustselect_live): relabelled in place in a new locale, added and removed by their value. - glTF models in Luau:
ui.model { src, fit }shows a.glbor.gltfin a 3D scene. It's read off the main thread and shows once read,fitscales it to that many units across, and it hears clicks and hovers as a group does. A changed file reloads the script, as an image does (3D scenes). It's zgx-script'sgltffeature, on in zgx-run (48.5 KiB of a build for shipping). - Read-only shared state for plugins (zgx-script):
ScriptHost::expose_shared_read(key, capability)lets plugins read and follow a shared key without writing it. A plugin'sset,update,pushand the rest on it, or bindin...
zgx 0.1.5
A patch: Rust and Luau code builds as before (every API change since 0.1.4 is an addition, and zgx-desktop and zgx-gpu 0.1.4 build against this zgx), and zgx-3d is a new crate. Two behaviours change, each below with what to check: a radio's, check box's, switch's or link's accessible name, and the button Enter or Space activates.
Changed: what to check in an existing app
Radios, check boxes, switches and links are named by their content, as ARIA names them, unless they are given a name of their own (named, Luau label). Before, a radio or link built as a button with its role changed (button().role(Role::RadioButton)) was named by its content only as it mounted, and a later change to that content left the name behind; a screen reader read the old text, or none. Menu items are left out: their menus name them.
- Migrate: a test or tool reading these controls' names through assistive technology reads their current text. A control whose name should differ from what it shows sets one with
named.
Enter and Space activate the button they were pressed on, as long as it is still there and focused. Before, a button whose own key handler closed it and moved focus (a dialog's button closing its dialog) activated the button focus moved to as well: at once on Enter, at release on Space.
- Migrate: a handler that meant a key on one button to click the next one calls it itself.
Added
- GPU views: an app's own wgpu drawing in a box (
zgx::compose::gpu_view(label, &view),zgx::gpu_view::GpuView, its painter azgx_gpu::viewport::Viewport): a 3D scene, a game, a decoder's output. The box is an ordinary one, so clipping, rounded corners, opacity, edge fades, masks, perspective tilts and hit testing apply. A frame is drawn when the box shows, resizes or changes scale, when the view is invalidated, and every refresh while it animates and shows; showing it repaints only its box (GPU views). - A new crate,
zgx-3d: a retained 3D world (meshes in metallic-roughness materials, a sun, point lights and a sky/ground hemisphere, a perspective or orthographic camera, parents and visibility) shown in any box through a GPU view. A still world draws nothing; changes before a frame draw one. Cuboids, spheres, planes, cylinders, cones and tori are built in, andOrbitturns and dollies a camera from drags, the wheel and pinches. - 3D scenes in Luau:
ui.scene3dwithui.mesh,ui.lightandui.group, drawn by zgx-3d in a GPU view: metallic-roughness materials, directional, point and hemisphere lights, and a camera to orbit by dragging and zoom by scrolling (3D scenes). Each number, vector and colour may be a state, a function or aui.tween/ui.springbinding: a still scene runs no script and draws nothing, and a spring steps in Rust. It is zgx-script'sscene3dfeature, off by default, because it links the renderer (wgpu), which a headless host (tests, tools, a server) otherwise leaves out.zgx-runturns it on, so apps it runs have it. A host built without it says which feature a script callingui.scene3dneeds. - Windows: input methods through the Text Services Framework, opt-in with
ZGX_TSF=1(IMM32 stays the default for now). Editors give Windows' input methods and text services a text store. They read the text around the caret, place candidates at the clause they convert, and insert at the caret: the emoji panel, dictation, the touch keyboard. For it, editors take an input method's clauses (TextEditor::set_preedit_clauses,ClauseStyle), each underlined, thick under the one being converted. They also take a replacement as one undo step (TextEditor::replace_range) and count UTF-16 offsets (utf16_len,byte_to_utf16,utf16_to_byte).EditorHandlemakes these edits (insert_text,replace_text,set_composition,commit_composition,cancel_composition,input_method_area) (windows). The clauses come from the input method's display attributes, and an input method asking where any range shows gets its box (EditorHandle::text_area,Scene::text_range_area: across its lines, through zoom and the editor's scroll), for its candidates and prediction list. - Windows: UI Automation finds text in zgx fields and documents (
ITextRangeProvider::FindText, which AccessKit left out): Windows Speech Recognition's "select ", Narrator's find and UIA tools find a word forward or backward, with or without case, within the range they ask. - Windows: an experimental Direct3D 11 renderer, off by default: built only with zgx-gpu's
d3d11feature (forwarded by zgx-desktop) and taken only withZGX_D3D11=1. It opens no Direct3D 12 device, so a window holds less memory and its driver rests; everything above the device draws as on Direct3D 12 (windows). zgx-runreaches the network: an app it runs has a fetcher forui.fetchand remote images, over HTTP and HTTPS (ureq, TLS by rustls with Mozilla's roots, no OpenSSL). Each request runs on a thread of its own, its body reacheson_chunkandon_eventas it arrives, and a cancelled fetch closes its own connection at once, even while the server is silent, while other fetches keep theirs, pooled for reuse. A read or write waiting five minutes ends a request. A host's own fetcher replaces it, andzgx_run::install_fetchergives it to a host made outsiderun. Itsfetchfeature is on by default, about 1.6 MB of a release binary;--no-default-features --features mimalloc,inspectleaves it out.View::hollow(): the pointer passes through a box while nothing it holds is displayed (hiddenorinvisible). A Luaupresencebox uses it: a page kept mounted but hidden no longer takes the presses meant for what is under it (a web page, the panes).Scene::window_pointandScene::window_recttake a point or box from the space a node is laid out in to the window, through its own and its ancestors' transforms and zooms.zgx inspecttargets and trees reach into open modals, popovers and menus, whose panels mount outside the view that owns them:"Save"orrole:button "Save"in an open dialog is found, and the tree shows the dialog under its owner (Ui::portals).
Changed
- macOS: an inset title bar's traffic lights move with a zoomed window's 40-point strip (centred in 40 × the zoom points, as far in as the zoom makes AppKit's inset), so a title bar laid out around them stays level at any zoom.
- macOS: frames presented as
CAMetalLayerdrawables (ZGX_PRESENT_DRAWABLE, or=autowhere its trial picks them) show a refresh or two sooner. They are paced by the view's own display link and kept in phase: when frames start waiting most of a refresh for a drawable (the pool full, as most launches of an M1 Max settled), one tick is skipped and they catch up. The pool is two drawables, not three. A frame's start to its showing: 16 ms against 27–32 on an M5 Max, 24 against 27–40 (median 36) on an M1 Max. The window server's CPU on an M1 Max drops from 34.2% to 31.3%, and each window holds 10 MiB less. Tiles, the default, are unchanged. - macOS:
ZGX_PRESENT_DRAWABLE=autojudges by the frames each path skips again, not by energy. A process can't measure the GPU's energy, only its busy time, and what a busy second costs depends on the GPU's clock (0.78 W on an M5 Max, 4.44 on an M1 Max). The energy rule chose drawables where they measure worse.ZGX_TRIAL_RULE=energykeeps that rule, uncalibrated. A GPU-heavy, full-rate app on an M1-class Mac may choose drawables itself (ZGX_PRESENT_DRAWABLE=1): about 150 mW less GPU and 5.7 points less window-server CPU, for about 4.7 points more app CPU. zgx inspect's layout warnings leave overflow past an edge that fades (fade_edges,fade_edges_x) alone: a strip of tabs fading at its end runs past it on purpose. Overflow past an edge without a fade still warns.
Performance
- CPU, against 0.1.4, by exact instruction counts (Linux arm64, with a screen reader on for both, as 0.1.4's benchmarks ran): anime −2.57%, heavy −0.68 to −0.69% (scrolling −0.36%), micro-vscroll-variable −0.53%, mount −0.51%, animate −0.30%, widgets −0.25%, and the apps and lists level or lower.
- style +0.10%: two approved trades. The
Signal::withinlining (+0.05%; heavy −0.69%, anime −2.57%), and #835, which takes back 0.1.4's arm64-only #784 to win on x86 (style −0.49%, heavy −0.30%, animate −0.50% there). The other changes since 0.1.4 make style 0.4% faster. - app-code and app-code-search +1.3%: they now free the 5,000-line document that 0.1.4 leaked when its editor closed. That's a one-time cost of about 1.2 M instructions, the same however long the editor was used; typing and scrolling cost what they did.
- style +0.10%: two approved trades. The
Fixed
- A script's values can't panic or abort the app from Rust: durations no
Durationholds (ui.after(1e300, f), a toast or tooltip delay ofmath.huge), a deadline past the clock,anime.utils.clampwith NaN bounds,roundPadandpadStart/padEndasked for absurd sizes, and anime timings that are no number. A NaN or infinite duration is a script error naming the line, a longer one waits a year (zgx::timer::MAX_SLEEP), and anime clamps as anime.js does (found by fuzzing). - Windows (
ZGX_TSF=1): the app setting an editor's text or selection while an input method composes in it ends the composition there, as the editor's preedit already ended. The text store kept the composition: ending it committed the cancelled text ("REPLACED候補"), and the input method's next write at the selection panicked. ScriptHost::on_errorhears an error raised again with the same message; it heard only the first.- A script's error banner shows over an open modal or popover and dismisses with its ×...
zgx 0.1.4
A patch: Rust and Luau code builds as before (every API change since 0.1.3 is an addition, and zgx-desktop and zgx-gpu 0.1.3 build against this zgx), unless it writes a zgx_gpu::GpuStats field by field (below). A few editor keys and clicks change, each below with what to check.
Changed: new fields
zgx_gpu::GpuStatsgainslayer_partial_repaints(layers repainted only where the window's damage reaches them). One written field by field ends with..Default::default().
Added
- Zoom an element, as CSS's
zoom:Styled::zoom(n)(Luauzoom = n) shows a node's subtreentimes as large, laid out again at that size and drawn crisply at it; zooms multiply with ancestors' and the window's, andzoom_fixed()(Luauzoom_fixed = true) keeps a node at its own size whatever zooms are around it.Scene::set_zoom,zoom,zoom_around,Ui::set_zoom_fixed;PlaneSplit::native_zoomfor hosts. At 1 nothing changes. A zoomed node paints as a cached layer at its exact scale, repainted only where the window's damage reaches it (styling). - Zoomable elements:
View::zoomable(state)(Luauzoomable = state) lets the user zoom a node in place: Control with the wheel over it steps the window's levels (Windows, Linux) and a pinch over it zooms (every platform), 25% to 500%, the innermost zoomable under the pointer taking the gesture and the window zooming elsewhere; in a scroll view what is under the pointer stays there.zgx lintreports azoombeside azoomable(zoomable-zoom). - Double-click selects a word of Chinese, Japanese or Thai, as Chrome does: those scripts put no spaces between words, and Unicode's boundaries took each character for one. zgx's new
cjk-thai-wordsfeature, on by default (and forwarded byzgx-desktopandzgx-script), finds words with ICU4X's dictionaries (北京 in 我们在北京大学, สวัสดี in สวัสดีครับ). The dictionaries are about 2.2 MB of a release binary, paged in when first used, with no startup or per-frame cost. Turn it off withdefault-features = falseon each zgx crate an app depends on. zgx's licence is nowMIT AND Unicode-3.0, for the Thai dictionary it carries (composition). - Lists copy as lists, as a browser's do: a list item's marker ("•", "3."), drawn as accessible-hidden text leading a
Role::ListItem(as markdown draws its lists), is now the list's. A selection takes no part of it, plain text leaves it out, and copied HTML is a<ul>, or an<ol start>from the marker's number, nested as the lists are. Screen readers hear where each item is in its list ("2 of 5") unless the app says so itself.zgx lintreports a marker left as text (list-marker-hidden) (composition). - Windows: a start says whether it read its shaders from FXC's disk cache or compiled them, and which it couldn't keep (
zgx_gpu::shader_cache_counts();ZGX_SHADER_LOG=1prints it with the first frame, asZGX_PROFILE=1's startup marks carry it). It isNoneelsewhere, and with crates.io's wgpu-hal.
Performance
- CPU, against 0.1.3, by exact instruction counts (Linux arm64, with a screen reader on for both, as 0.1.3 always ran; with none, as desktops run, 0.1.4 does less again): widgets −93.7% (a mount lays out its text inputs once, below), style −13.5%, animate −11.2%, heavy −9.5 to −9.9%, anime −5.7%, script-update −4.2%, the apps, lists and scrolls −0.2 to −2.7%; lab-stream and list-state level. No scene is slower.
- macOS: a focused field left alone takes about 180 MB less memory. Its caret is a Core Animation layer, shown and hidden each blink with no frame drawn, where it was a frame each blink: the Metal driver holds about 176 MB for its command queue for some 8 s after any frame, so a blinking caret kept the window's footprint at 210 MB, where it now falls to an idle window's 30 (M5; GPUI Fast, MyGo and Rune keep 219–404 MB blinking a caret). The window does no GPU work while the caret blinks: two small wakes a second, none while the field is unfocused or the window unseen (its window server cost is what GPUI's blinking frames cost, with 1/13 of the app CPU). It is the editor's caret, drawn in frames as before, wherever Core Animation's could differ (
docs/composition.md);ZGX_NATIVE_CARET=0keeps it everywhere.
Changed: what to check in an existing app
A mount's text inputs are laid out once, when it ends, and geometry read during a mount is reactive: a mount of many inputs is linear, not quadratic (the widgets benchmark −93%), including one whose rows each run a callback of the app's own. An observer in the mount that reads geometry (Scene::bounds and the boxes made from it, from a reactive style or a binding) runs again once the mount's layout settles, if what it read has moved: a style derived from an input's width of 160 settles on 160, where it read an earlier layout's (such as the default 240) and kept it. One whose reads keep moving runs again at most a few times, then at the next flush.
- Migrate: derive what a callback makes from its geometry read each time it runs. One that keeps only its first read (a
Cellset once) keeps a value from before layout; an app relying on the earlier width needs nothing else. - A
TextMeasurer,TextShaperorFontTextShaperis a cached computation: it may be called any number of times, in any order. The signals it reads keep their subscriptions; writing a signal from one is unsupported.
Editor keys go by words and paragraphs as each platform's text fields do. On Windows, Control-Right and Control-Delete stop at the next word's start (past the spaces), not the end of the word. On Windows and Linux, Control-Up and Control-Down go to the start of a paragraph, not up or down a line. On macOS, Control-A and Control-E go to a wrapped paragraph's edges rather than its row's. Control-K kills into a buffer that Control-Y yanks back, and Control-T swaps characters.
A fourth click keeps a triple click's paragraph selected, as Chrome's do: presses in a row count on past three (Rust EventContext::click_count, Luau's clicks), where they went round to 1 again, and selection takes three and on as a paragraph.
- Migrate: a handler that relied on the count going back to 1 after three reads
(clicks - 1) % 3 + 1.
Shift-Right extends back through right-to-left text, as Chrome does: Shift with Left or Right extends a page's selection the way its block runs (direction), so in a right-to-left paragraph Shift-Right grows it rightward on screen.
Copied HTML keeps code blocks and colours: a selection copied from a page (View::selectable) puts monospace text of more than one line in <pre>, and runs coloured apart from the text the selection starts in carry their colour (<span style="color:…">); body text and links carry none, so a dark theme's white text doesn't paste invisible.
macOS: Home and End in an editor scroll to the text's start or end and leave the caret where it is, as NSTextView and Chrome on Mac take them. Shift-Home and Shift-End extend the selection to the text's ends. ⌘← and ⌘→ go to a line's ends, ⌘↑ and ⌘↓ (and ⌘Home, ⌘End) to the text's, as before. Windows and Linux are unchanged.
- Migrate: a test or script pressing Home or End in an editor on macOS to move the caret presses ⌘← or ⌘→.
A password field takes no input method, and on macOS types with secure keyboard entry on, as browsers' password fields and AppKit's secure text fields do: a candidate window no longer shows what is typed, and other processes can't read the keys while one has the keyboard (EditorHandle::is_secure).
- Migrate: nothing, unless an app relied on composing (pinyin, kana) in a secure field: its keys now type their characters.
Fixed
- A scroll under a rounded clip no longer leaves corners behind: a panel with rounded corners and
overflow: hiddenaround a scrolling view, scrolled down and back, trailed copies of its corners down the view's edge (since before 0.1.2). Copying renderers now repaint each such corner where it is and where the scroll took its pixels; a scroll with no rounded clip around it costs what it did. - Windows: a frame of the app's own timing ready just before the compositor latches the next refresh waits until just past it, rather than making and missing the refresh in turn: with a timer drifting near the latch, missed refreshes in an 8 s lab stream went from 45–76 to 0–1.
- X11: a wheel notch from a pointer with no scroll valuators (XTEST's, a remote desktop's) scrolls one line and zooms one level, not two.
- A press in a zoomed or scaled editor lands on the character it shows on, and a drag selects the run it covers; extending a selection in a zoomed scroll view scrolls just enough to show its end.
- macOS: each of an app's menus shows once in the menu bar (Edit, Edit, View, View before).
Crates: zgx, zgx-components, zgx-gpu, zgx-desktop, zgx-script, zgx-run and zgx-ghostty-sys 0.1.4.
Licence: zgx is now MIT AND Unicode-3.0: it carries ICU4X's Thai word dictionary (about 2.2 MB of a release binary, paged in when a Thai word is first selected), under the Unicode licence shipped as data/LICENSE-UNICODE in its package. default-features = false on each zgx crate leaves the cjk-thai-words feature, and the dictionary, out.
Instructions per scene against 0.1.3 (exact counts, Linux arm64, a screen reader on for both sides as 0.1.3 always ran; with none, as desktops run, 0.1.4 does less again):
| scene | 0.1.3 | 0.1.4 | change |
|---|---|---|---|
| widgets | 2,190.8 M | 138.2 M | −93.7% |
| style | 1,439.4 M | 1,245.4 M | −13.5% |
| animate | 312.1 M | 277.1 M | −11.2% |
| heavy-stream | 1,079.0 M | 972.5 M | −9.9% |
| heavy-both | 1,130.8 M | 1,023.5 M | −9.5% |
| anime | 3,211.6 M | 3,027.8 M | −5.7% |
| sc... |
zgx 0.1.3
A patch: Rust and Luau code builds as before (every API change since 0.1.2 is an addition), unless it writes one of the three structs below field by field. Some defaults change, each below with what to check: zoom and find in page are on, keys and cursors behave as a browser's and AppKit's, and Windows presents and serves assistive technology differently.
Changed: new fields
New features add fields to these structs; each has a default, and one written field by field ends with ..Default::default() (or ..LocalTransform::IDENTITY). Every type and signature that was in 0.1.2 is unchanged: LocalTransform::matrix and PaintItem::matrix still give an Affine, and a tilt's map in perspective is beside them (LocalTransform::projective, PaintItem::projective, zgx::affine::Projective).
zgx::scene::LocalTransformgainsrotate_x,rotate_yandperspective(3D tilts, below).zgx::scene::PaintExtrasgainsprojective.zgx::trace::Frame, a frame of the devtools trace, gainsnotes(why the frame did what it did) andgpu_micros(the GPU's own time).
Changed: what to check in an existing app
A window zooms, and finds in its text, as a browser's page does (both below, under Added): Command (Control elsewhere) with plus, minus or 0 zooms a window, and Command-F (Control-F) opens a find bar. On Windows and Linux, Control with the wheel zooms too, and scroll views no longer scroll on it. A keymap or menu item binding those keys keeps them, and a node handling the wheel keeps a Control-wheel, so an app that has its own is unchanged.
- Opt out:
Ui::set_zoomable(false)(Luauui.window.zoomable:set(false)) andUi::set_find_in_page(false)(Rust; a Luau setting follows).
Enter activates a focused control as it goes down, and toggles no checkbox; Space follows no link. As a browser's controls and AppKit's take the keys: a button (and anything with on_click) runs on Enter's press, not its release, and on Space's release as before; a link takes Enter alone (Space scrolls); a checkbox, radio button or switch takes Space alone, so Enter goes on to the form's default action.
- Migrate: a checkbox meant to toggle on Enter handles the key itself (Rust
.on_eventonKeyDownofKey::Enter, Luauon_key), setting its state; a test pressing Enter and expecting the action on its release expects it on the press.
Buttons, checkboxes, menu items and sliders keep the arrow cursor, as Chrome's controls and AppKit's do; only links show the pointing hand, and text fields the I-beam. They showed the hand (sliders a resize arrow) when they stated no cursor.
- Migrate: a control meant to show the hand states it:
.cursor(Cursor::Pointer)(Luaucursor = "pointer").
Option-Tab and Command-Tab (Alt and Super elsewhere) no longer move focus: they are the system's, or a field's literal tab, and go on unhandled. Tab, Shift-Tab and Control with either still do, and Control-Tab now leaves a code editor that indents on Tab, as AppKit's text views do.
- Migrate: an app meant to move focus on Option-Tab binds it (a keymap entry, or
on_key) and moves focus itself.
A display: none node's children report a 0x0 box (Scene::bounds, on_resize, zgx inspect), as the node itself did and as CSS gives them: they reported their content's size at no width. They are no longer measured again each time their parent lays out.
- Migrate: code reading a hidden child's size to plan its layout reads it once the child shows (its
on_resizefires then).
Windows: a window builds its accessibility tree only once a client reads it. Windows' text services ask every window for its client area as it shows, with no assistive technology running, and zgx answered with its whole tree, built and kept in sync from the first frame (15–22% of a frame in lab and vlist). That request now gets AccessKit's placeholder root. UI Automation's request for the root (Narrator, NVDA) builds the tree at once, as before, and a client reading past the placeholder (its children, focus, a point, a pattern) builds it too, a focus event telling it to read again.
- Migrate: a test or tool reading a window through MSAA (
OBJID_CLIENT) waits for that focus event, or reads through UI Automation.ZGX_ACCESSIBILITY_LOG=1logs the requests and when the tree is built.
Windows: a window presents through a swap chain of its own, flip-sequential, its buffers the retained frame (as macOS's surfaces are) and each frame's changed regions passed to the compositor as dirty rectangles, rather than copying the whole frame into wgpu's buffer every frame. The app's GPU does 33–45% less in animate, vlist and lab, 12% less in heavy, the compositor less too, and the first text shows 346 → 280 ms after launch.
- Migrate: nothing to change. If a window draws wrongly on some GPU or driver,
ZGX_DXGI_PRESENT=0presents through wgpu's swap chain, as before, for this release (and please report it). A renderer presenting into a caller's visual always does.
Added
- Find in page: Command-F (Control-F elsewhere) opens a find bar at the window's top right, as a browser's: matches across all the window's text, fields and virtual lists' rows (those not mounted too), case and accents ignored, marked behind the text (theme tokens
find.match,find.current), Enter and Command-G (Control-G, F3) stepping, Escape closing with the match selected. A match in a field is scrolled to without moving its caret, until the bar closes on it. On macOS, Command-E finds the selection, and an app's Edit menu gains AppKit's Find submenu.Ui::find_text,find_next,find_status,find_results. An app whose keymap or menu binds Command-F, -G or -E keeps its own. - Zoom, as a browser's page zoom: Command (Control elsewhere) with plus or equals zooms a window in, minus out, 0 back to 100%, through Chrome's levels (25% to 500%). The window's content draws at the display's scale times the zoom, so layout reflows in a smaller or larger viewport and text and paths stay crisp; scroll views keep what was at their top. On Windows and Linux, Control with the wheel zooms too, a level a notch, when nothing under the pointer takes the wheel (a canvas's or a script's
on_wheelthat zooms keeps it); on macOS neither the wheel nor a pinch zooms, as in Safari. A window with a frame autosave name keeps its zoom and reopens at it.Ui::zoom,set_zoom,zoom_in,zoom_out,reset_zoom; Luau'sui.window.zoomandui.window.zoomable, states that follow the keys and set the zoom (ScriptHost::window_zoomfor a Rust host); on macOS an app's View menu gains Zoom In, Zoom Out and Actual Size. An app whose keymap or menu binds the keys keeps them; with Option, or Command and Control both, they are the app's (zterm's font size, ⌘⌥=). Native views follow it: terminals and web pages are placed in the zoom's points, a terminal's text drawn at its scale, a page at its own zoom times the window's (WKWebView's page zoom, WebView2's zoom factor). At 100% nothing changes: the content scale is the display's, exactly. radio(label, selected, value)andradio_group(label): radio buttons as a browser's and AppKit's, one Tab stop per group landing on the checked one, the arrows (all four) moving and choosing, wrapping at the ends. AnyRole::RadioGroup(Luaurole = "radiogroup",segmented) is such a group now; its arrows stopped at the ends and chose nothing.on_aux_clickin scripts: the middle button (or another beyond the primary and secondary) pressed and released on a node, as the DOM'sauxclick(a tab closed on a middle click, as browsers do). It is told{ button, x, y, local_x, local_y, shift, ctrl, alt, meta, cmd },buttonbeing"middle"or the platform's number; the innermost node with one takes it.- Typing in a list box (
Role::ListBox, Luaurole = "listbox") or a keyboard virtual list (keyboard_navigation, Luaukeyboard = true) jumps to the next option or row whose text starts with what was typed, as a browser's list box and AppKit's tables do: rows not mounted by theirrow_text, without mounting them. A list box's Page Up and Page Down move a page of its options.
Drawing
- Blend modes, as CSS's
mix-blend-mode: a view blends with what lies beneath it in any of the W3C's fifteen modes (multiply, screen, overlay, darken, lighten, color-dodge, color-burn, hard-light, soft-light, difference, exclusion, hue, saturation, color, luminosity):Styled::blend(BlendMode::SoftLight),Scene::set_blend, Luaublend = "soft-light"(a literal or a state). Inside a layer it blends with that layer, not the window. On the wgpu path and in the software renderer. - 3D tilts in perspective, as CSS's
perspective() rotateX() rotateY()about a node's origin:Styled::rotate_x,rotate_yandperspective(Luau props of the same names; anime keysrotateX,rotateY,perspective). The layer composites as a projected quad, sampled perspective-correctly, on the wgpu and direct Metal paths and in the software renderer; its bounds, damage and hit testing follow the projection. - Layer shaders: a node's cached layer composites through WGSL of the app's own (
fn layer(input: LayerInput) -> vec4<f32>,zgx_sample(p)reading the content elsewhere in the box), for a ripple, a chromatic split, scanlines or a dissolve:Styled::layer_shader,Scene::set_layer_shader, Luaulayer_shader = ui.layer_shader(wgsl), with uniforms (set_layer_uniforms,layer_params) and the animatedlayerTimeandlayerParam0..15, which composite again without repainting. - Frames with a blend or a layer shader draw through wgpu rather than macOS's direct Metal path, for now.
Text
- Selected text drags, as in AppKit's text views and browsers: a press on a field's selection, or selectable text's, moved past the drag threshold drags it (
DraggedText). Dropped in a field it goes where the caret shows, selected: within its field it ...
zgx 0.1.2
Fixed
A Luau app starts in a new project. Since luau0-src 0.22.1 (Luau 744, 2026-10-24), an app depending on zgx-script 0.1.0 or 0.1.1 that resolved its dependencies afresh panicked as its first script loaded: "Error configuring Luau … attempt to load a text chunk (mode is 'b')". zgx's own lockfile held 0.22.0, so its tests never met it. zgx-mlua 0.12.3 holds Luau at 0.22.0 (Luau 740) until mlua works with 744, and a release now checks that a new app, resolving every dependency afresh, runs a script.
- Migrate: depend on 0.1.2 (
cargo update -p zgx-script). On 0.1.0 or 0.1.1,cargo update -p luau0-src --precise 0.22.0+luau740fixes an app.
Nothing else changed since 0.1.1.
Published
zgx, zgx-components, zgx-gpu, zgx-desktop, zgx-script, zgx-run and zgx-ghostty-sys 0.1.2, with the fork zgx-mlua 0.12.3. The rest are as 0.1.1 published them.
zgx 0.1.1
A patch: a Luau app runs as before, and Rust code builds as before unless it matches every variant of one of the enums below or writes one of the structs below field by field (each says what to add). Two fixes change what a box looks like, as CSS draws it; each says what to check.
Changed: new variants and fields
New features add variants to these enums and fields to these structs. Code naming every variant, or every field, adds the new one. DragPhase, profile::Phase and zgx_script::Value are #[non_exhaustive] from now on, so a match on one ends with _ once, and a variant added to them later breaks nothing.
zgx::input::DragPhase::Spring(spring-loading, below): amatchon a drag's phase ends with_(Springbefore it to handle it).zgx::profile::Phase::Wait(waiting for the display, apart from a frame's work): amatchends with_.zgx::semantics::Role::Article(role = "article"): amatchon a role addsArticle. It comes last, so the other roles keep their values (ascasts).zgx_script::Value::Image(aBitmapin state): amatchon a script value ends with_.zgx_desktop::WindowOptionsgainslevel,shadowandcovers_display;zgx::decoration::Decorationclip_feather;zgx::scene::RoundRectfeather;zgx_gpu::GpuStatspatched_items. One written field by field ends with..Default::default().GpuRenderer::debug_stencilanddebug_stamped_framesdo nothing and count nothing (deprecated): frames no longer draw through a stencil.
Fixed
A rounded box that clips cuts its children at its padding box, inside its border with each corner's radius less the border, as CSS's overflow: hidden does: clipped at its outer edge with its full radius, a child filling a bordered card painted over the border where it curves.
- Migrate: a child meant to reach the box's outer edge, under its border, takes
border_overon the box: the border then takes no room and paints over the child.
Rich text shrinks no further than its longest word, as plain text and CSS's min-width: auto do: a rich_text (ui.rich) squeezed in a row narrowed to a single glyph's width before, one glyph a line.
-
Migrate: where a paragraph should break inside its words to fit a narrow row, state
.min_w(0.)(min_w = 0). -
An image's
ditherandcharactersfollow a state or computed value, assrcdoes, and apply to an image with afallbacktoo (View::ditherpanicked there as the script lowered). -
An image drawn in a cached layer (its own opacity,
layer = true) repaints as the box lending it shader uniforms changes them: its characters' reveal no longer freezes. -
A drag that leaves a destination and comes back waits out the spring delay again (it sprang at once, or never again).
-
A focused controlled input (
value = function) shows a change made elsewhere at once (a chat's composer cleared on send kept the sent text); its answer to an edit still waits until it is left. -
A fixed box that shrank grows back when its content outgrows where it stopped.
-
SVGs keep their proportions in a box of others' (SVG's default
preserveAspectRatio) rather than stretching to it. -
A window's first layout is at the size it opened at, not the size asked for and again on
Resized. -
X11: an app no longer hangs when its input method (ibus-x11) has gone. Wayland: a window left maximized opens maximized from its first frame.
-
MarkdownBlock(ui.markdown's blocks) is typed by itst: refine on it (if b.t == "code") and its fields are typed.zgx checkmay now point at a field read without refining; nothing changes as it runs.
Drawing and animation
- Images draw as characters (
characters: an ASCII-art rendering in a 5 by 7 font in the shader, no glyph texture), revealed from a front measured in the window that every image shares, and dithered (dither, a Bayer threshold per dot) (media). The software renderer draws them too. - Image effects compile in a pipeline of their own when an image first draws with one: an app that draws none pays nothing for them (about 5 M instructions at start and 3 K a frame, had they been in the quad shader).
clip_feather: a rounded clip fades its content out over a distance inside its edge, and a clip inset rounds its inset box, so a round box stays round as it closes in.agents_introis a first-launch intro made of them: a picture materialising out of characters over the desktop, cards flying in. Its agents are placed in percentages of the scene.- Windows (
WindowOptions):level(always on top),shadowandcovers_display. - anime: a delayed tween of a property another animation holds takes it over when it starts, from where it was left, rather than cancelling it when made.
- Canvases pan and zoom without drawing again (
View::canvas_view;view = statein scripts), over a grid of dots or lines drawn in Rust (View::grid_fill,bg_grid).ui.canvas { each = state, draw_item = … }draws a list's items one by one, again only those written. - An image shows a
Bitmap(src = bitmap, a state or function of one), and images go through the clipboard (Platform::clipboard_image,set_clipboard_image;ui.clipboard.read_imageandwrite_imagein scripts, off the UI thread), on Wayland too. - Scrolling moves content by whole device pixels, as Safari's and AppKit's do: box edges and hairlines no longer shimmer as they move, and text no longer drifts from its background by half a pixel (
Scene::set_pixel_scale).
Drag and drop
- A drag resting on a destination springs it open (
DragPhase::Spring;on_drag_springin scripts,falseto decline) after the system's spring-loading delay (macOS's setting, 0.5 s elsewhere), or at Space, once a visit, as Finder's folders and Safari's tabs do. - A drag goes on when its source is hidden or removed (a tab switched away from), as in browsers and AppKit; a source disabled or under a modal still cancels it.
drag_preview_anchoredhangs a preview by a fraction of its own size (below the pointer, say).Ui::snapshot(el:snapshot,term:snapshot) pictures a node from the window's last frame, for a tab's preview of its panes (macOS).
Scripting
ui.markdown_viewdraws markdown (paragraphs, headings, lists, quotes, tables, code blocks with a Copy button), selectable, in the theme's colours: as its text changes it builds only the blocks that did, and a reply streaming in grows its last block in place.ui.resource(load, { initial })loads data in a task as states (data,loading,error,status,reload), again when what it read changes;ui.form { fields, validate, submit }checks fields as they are typed and controls bind withfield = form.fields.email;ui.trygives a value or its error as text;ui.taskreturns aTaskthat cancels.provide = { name = value }gives a subtree values thatui.use_context(name, default)reads, as React's context does.ui.listtakesdata(rows built from their items, asui.each's),key(rows follow their items as the list changes),measured = true(wrapped rows measured as they mount) withfollow(a chat resting at its end), and its element scrolls to a row (el:scroll_to_row(i, { align });NodeHandle::scroll_to_rowin Rust).ui.text { count }takes a state as its text, followed in Rust.ui.button { variant = "primary" }("ghost","danger").role = "article"names a message or post.- A state path picks a list's item by its fields (
{ "c1", { id = "m5" }, "text" }), andstate:append(path, text)adds text there without copying it into the script.ui.sorted'sfiltertakes fields' tests, read in Rust ({ sector = "Tech", symbol = { contains = "AB" } }). ui.fetchhears a response as it arrives (on_chunk, andon_eventfor server-sent events), and a cancelled fetch stops; a host streams withScriptHost::streaming_fetcher.- Copying a selection over a Luau list copies its rows not mounted (their text, or
row_text), and copied HTML keeps headings and links. zgx lint:canvas-view(a canvas drawing again to pan or zoom),list-measured(wrapped text in a list of fixed rows) anddraw-item-state(adraw_itemreading state).- The chat example streams replies as markdown; the team example loads a list, edits it in a modal form and confirms a removal with the above.
Devtools for agents
zgx inspectdrives a running app (zgx-run --inspect, orZGX_INSPECT_SOCKET=1) over a socket only its user can open, text by default and--json:tree,node(with the script line that made it),find,stateandset,errors,lint,frames(each frame's time by phase, the states written, what ran and why),snapshot(a node as the window last drew it, as a PNG), andclick,type,key,scroll,command,wait.recordkeeps the gestures a window receives (a trackpad's fingers and momentum) as a text tape, andreplayplays one back through the window's own input path, answering with its frames: how many, the time between them, those shown late. Read once. It costs nothing unless built with zgx-script'sinspectfeature.zgx inspect mcpserves them as MCP tools on stdio (zgx_treetozgx_recordandzgx_replay):claude mcp add zgx -- zgx inspect mcpadds them.zgx_script::driveris their vocabulary for tests too: targets (#id,"Send",role:button,@12,#list row 3,a >> b,[2]), actions and a compact tree;TestApptakes targets where it took ids (a bare id means what it did) and gainstarget,treeandkeys....