zgx

Web views and terminals

Native content laid out as nodes, and the app’s menu commands. A web view is a WebKit page (macOS) or a WebView2 page (Windows, the desktop host’s webview2 feature; Linux has none yet, see the Linux notes), and a terminal a Ghostty surface (macOS, the ghostty feature); each is placed, sized, rounded and clipped like any node, and zgx draws over it (a frosted popover over a page works). Without a native host (headless, other platforms) each shows a labelled placeholder box and its controller still answers. Pages and sessions belong to the host, not to a run, so a reload keeps them.

ui.webview(props: WebViewProps) -> Node
ui.web(opts: WebOptions?) -> Web
ui.web(id: string, opts: WebOptions?) -> Web
ui.terminal(props: TerminalProps) -> Node
ui.term(id: string, opts: TermOptions?) -> Term
ui.command(opts: CommandOptions) -> ()
Function
webviewshows a page: its own, or a ui.web controller’s (web = …)
weba page’s controller, usable before and after its view mounts. ui.web(opts) makes a page for this run; ui.web(id, opts) returns the host’s page for id, made with opts the first time, kept across reloads until web:close()
terminalshows a session (term = ui.term(id))
termthe session for id, started the first time with opts; later calls return the same session, their handlers replacing the previous run’s
commandan app command: the host shows it as a menu item (ScriptHost::commands) and runs it when chosen, its key working wherever focus is

Web view options

ui.webview takes these, ui.web the first six, and any property and event:

OptionTypeDefault
transparentbooleanfalseno page background of its own: the node’s shows where the page is transparent
profilestring"default""default" shares cookies and storage, "ephemeral" keeps nothing, any other name is a persistent store of its own
inspectablebooleantrue in debug buildsWeb Inspector (web:devtools())
user_agentstringWebKit’s
messagesbooleantruethe page’s window.zgx.postMessage(value) reaches on_message
new_windowsbooleantruewindow.open and target=_blank reach on_new_window (and open nothing)
webWeba page for the nodea controller, so the page outlives the node (a tab switched away and back)
srcReactive<string>nonethe URL; the page navigates when it changes
html, base_urlstring, string?nonea page from markup, loaded once
on_navigate(e: WebEvent) -> ()nonee is { kind, url }, kind one of "started", "committed", "finished", "failed", "changed"
on_load(url: string) -> ()nonea navigation finished
on_title(title: string) -> ()none
on_message(value: any, origin: string, main_frame: boolean) -> ()nonewhat the page posted, as Luau data, and who posted it: the sending frame’s origin (https://example.com) and whether it is the top frame. Any page the view shows, and any iframe in it, can post: check origin before acting on a message
on_error(url: string, err: string) -> ()nonea navigation failed, or the page’s process ended (url empty)
on_new_window(url: string) -> ()none

A Web controller’s url, title, loading, progress, can_go_back, can_go_forward, focused, error and pointer ({ x, y } in points while the pointer is over the page, else nil) are tracked like state: a text or computed reading them follows the page. Its methods: load(url), html(html, base_url?), back(), forward(), reload(), stop(), focus(), blur(), devtools(), zoom(z), run(js), eval(js, function(value, err) … end) (the script’s value as Luau data, or nil and the error), and close().

Terminal options

ui.term takes these; ui.terminal takes term and any property and event:

OptionTypeDefault
term (terminal)Termrequiredthe session to show
commandstringthe login shella command line
cwdstringhomewhere it starts; "~" is home
env{ [string]: string }noneadded to the environment
font_sizenumberGhostty’spoints
on_title(title: string) -> ()nonethe program set its title (OSC 0/2)
on_pwd(pwd: string) -> ()nonethe shell reported its directory (OSC 7)
on_process(name: string) -> ()nonethe foreground program changed
on_exit(code: number) -> ()noneits process ended
on_close(alive: boolean) -> ()noneit asks to close: its process exited (alive = false), or a close binding ran with something still running
on_bell() -> ()none
on_command(exit_code: number?, duration_ms: number) -> ()nonea command finished (shell integration)
on_notify(title: string, body: string) -> ()nonethe program asked for a notification

A Term’s id, title, pwd, process, pid, focused, bell (it rang since it last had focus), progress (OSC 9;4: 0 to 1, negative while indeterminate, nil when none), exited (the exit code, once it ended), columns, rows and pointer are tracked like state. Its methods: focus(), blur(), input(text) (as typed: "ls\r" runs ls), paste(text), binding(action) (a Ghostty binding action: "copy_to_clipboard", "clear_screen", "increase_font_size:1"), needs_confirm() (whether closing would end something other than an idle shell), snapshot(max_width?) (a picture of what it shows now, at most max_width pixels wide, 480 if not given: the src of a ui.image showing it, as "host:…", replaced by each call and forgotten as it closes; nil before it shows or where the platform takes none, macOS taking it from Ghostty’s last frame) and close().

Command options

OptionTypeDefault
idstringrequiredunique among the run’s commands
titlestringthe idthe menu item’s text
menustring"File""File", "Edit", "View", "Window" or any other menu
keystringnonemodifiers and a key joined by +: "cmd+shift+d", "cmd+alt+arrowleft", "cmd+[", "ctrl+tab". cmd is Command on macOS and Control elsewhere; super names the Windows or Super key itself. ui.shortcut_text(keys) shows keys as the platform does ("cmd+shift+z": ⇧⌘Z on macOS, Ctrl+Shift+Z elsewhere), for a tooltip or a list of keys
run() -> ()requiredcalled when it is chosen
separatorbooleanfalsea separator line in menu instead of a command (id and run are not needed)

Commands are the app’s menu bar, and their keys are its menu items’ shortcuts: zgx run (and zgx_run::run_hosting, for a Rust host serving its script a fetcher or actions) installs them. A Rust host that makes its own window gets the same with zgx_run::serve_commands(&host, cx) in its Application::run, keeping the effect it returns; headless, a test runs a command with ScriptHost::run_command(id).

Windows

ui.window(opts: WindowOptions) -> ()

ui.window { id, open = state, node } declares a window of the app’s own, a settings window say: it is open while open is true, closing it sets open false, and its node is its content. Declared at a script’s top level, as commands are, each load declares them again; what a window shows follows reloads. The windows share the app’s state: a switch in the settings window changes what the main window shows (and ui.theme.set restyles every window). zgx-run opens them, and a Rust host opens them from ScriptHost::windows() with ScriptHost::window_view(id) (zgx_run::follow_windows does both).

OptionTypeDefault
idstringrequiredunique among the run’s windows
openState<boolean>requiredthe window shows while true
titlestringthe id
width, heightnumber480, 360points
frame_autosavestringnoneits frame saved under this name as it moves and resizes, and reopened where it was left (macOS, Windows, X11; on Wayland its size and maximized state)
tabbing"automatic", "preferred", "disallowed""automatic"AppKit’s window tabs (macOS): as the user’s setting says, always, or never (a window with tabs of its own)
[1]Noderequiredits content
local settings = ui.state("settings", false)
local dark = ui.persist("dark", true)
ui.command {
	id = "settings",
	title = "Settings…",
	key = "cmd+,",
	run = function()
		settings:set(true)
	end,
}
ui.window {
	id = "settings",
	title = "Settings",
	width = 420,
	height = 240,
	open = settings,
	ui.column { p = 24, ui.checkbox { label = "Dark appearance", value = dark } },
}
return ui.button { on_click = ui.set(settings, true), "Settings…" }

The title bar

ui.window.titlebar_area: State<WindowRect?>
ui.window.controls: State<WindowRect?>

Content under an inset or hidden title bar stays clear of the window’s own buttons by reading where it may draw, as the web’s Window Controls Overlay gives it. ui.window.titlebar_area is the strip the title bar leaves, less the window’s buttons. ui.window.controls is the buttons. Each is { x, y, width, height } in points from the window’s top left, or nil for a standard title bar, in fullscreen, and where the platform does not say (macOS and Linux, for now). Both follow resizes, scale and fullscreen. zgx run keeps them; a Rust host passes its window’s with ScriptHost::window_chrome.

return ui.row {
	h = 44,
	window_drag = true,
	items = "center",
	-- Clear of Windows' buttons at the right, or macOS's at the left.
	pl = function()
		local area = ui.window.titlebar_area:get()
		return if area then area.x + 12 else 84
	end,
	pr = function()
		local area, buttons = ui.window.titlebar_area:get(), ui.window.controls:get()
		return if area and buttons and buttons.x >= area.x + area.width then buttons.width + 12 else 12
	end,
	ui.text { "Title" },
}

Example

local url = ui.state("url", "https://example.com")
local web = ui.web("docs", { profile = "ephemeral" })
local term = ui.term("main", {
	on_title = function(title)
		print("terminal:", title)
	end,
})

ui.command {
	id = "reload",
	title = "Reload Page",
	menu = "View",
	key = "cmd+r",
	run = function()
		web:reload()
	end,
}
ui.command { menu = "View", separator = true }
ui.command {
	id = "clear",
	title = "Clear Terminal",
	menu = "View",
	key = "cmd+k",
	run = function()
		term:binding("clear_screen")
	end,
}

return ui.row {
	w_full = true,
	h_full = true,
	p = 8,
	gap = 8,
	items = "stretch",
	ui.column {
		grow = 1,
		gap = 6,
		ui.row {
			gap = 6,
			items = "center",
			ui.button {
				disabled = function()
					return not web.can_go_back
				end,
				on_click = function()
					web:back()
				end,
				"Back",
			},
			ui.text {
				function()
					return if web.loading then "Loading…" else web.title
				end,
				truncate = true,
				shrink = 1,
				min_w = 0,
			},
		},
		ui.webview {
			web = web,
			src = url,
			grow = 1,
			rounded = 10,
			on_navigate = function(e)
				print(e.kind, e.url)
			end,
		},
	},
	ui.terminal { term = term, grow = 1, rounded = 10 },
}

Gotchas

  • ui.term(id) without options only looks a session up, keeping its handlers; with options it replaces them with this run’s. Either starts one, with default options, when id has none (or its session closed). Give the options wherever the session may start.
  • Sessions outlive reloads and the nodes that show them: a reload keeps every shell running, and moving the terminal node (another split, another tab) moves the terminal with its process. End one with term:close(), usually from on_close.
  • ui.term rejects an unknown option. The on_* options are a session’s events, not a node’s: give them to ui.term, not ui.terminal.
  • A page from ui.web(id) is kept until web:close(); ui.web(opts) and a webview without web make a page for the run (a reload makes a new one).
  • Over a page or a terminal that takes input, zgx’s nodes hear no pointer moves (they had their leave): follow web.pointer or term.pointer instead. The terminal reads its own keyboard, IME and mouse; app shortcuts reach the app through its commands, whose keys work wherever focus is.
  • Terminals hand the right clicks their program does not use to the node, so a context_menu on the terminal node opens over it.
  • Each successful load declares its commands afresh, replacing the last; a failed reload keeps them. Declaring an id twice in one run is an error.
  • on_message and eval get the page’s JSON as Luau data, typed any: check its shape before using it.
Edit on GitHub