zgx

Text

ui.text shows one string in one style; ui.rich shows runs of differently styled text (ui.spans) as one paragraph. Both follow state without calling the script when their text is a template or a state given as it is (ui.text { count }: a component’s ui.use_state, which no template can name, as its value), and call it again only for a function.

ui.text(content: Text | TextProps) -> Node
ui.rich(props: RichProps) -> Node
ui.span(content: Text | SpanProps) -> Span

type Text = string | number | (() -> TextValue)
type TextValue = (string | number | boolean)?

ui.text "Hello", ui.text(function() … end) and ui.text { "Hello", text_size = 18 } are the same element: the table form takes the text first and properties after it. A string child of a container (ui.column { "Hello" }) is a ui.text too.

Text

FormShows
"Count: {count}"the string, with {name} replaced by the state named name, followed in Rust (no script call per change)
"{user.name}"a field of a state after a dot ({rows.1}: a list’s first item); nothing where there is none
"{{" "}}"literal braces; braces around anything but a name ({not a name}) are literal too
a numberit, as written (3, 2.5)
a functionwhat it returns, run again when the state it reads changes: strings as they are, numbers and booleans as text, nil as nothing

A template’s {name} looks for, in order: the calling module’s own ui.state or ui.persist of that key, the script’s, then ui.shared. A list shows its items joined by ”, ”.

Text options

Any property; these are the text ones (they also set the default for text inside a container that has them):

OptionTypeDefault
[1]Text""the text
literalbooleanfalseshow a string as written, braces and all: for data (code, titles, messages) that may hold {name}
text_sizenumber16points
colorColor"#e5edf7"
weightnumber400100 to 900
boldbooleanfalseweight 700
italicbooleanfalse
fontstringthe host’s"mono" (or "monospace"), "sans", "serif", or a family name
align"start" | "left" | "center" | "right""start"within the text’s own box
wrapbooleanfalsewrap at the box’s width
linesnumbernoneat most this many lines
truncatebooleanfalseend an overflowing line with ”…”
line_heightnumberthe font’spoints
letter_spacingnumber0points
tabularbooleanfalsetabular figures (OpenType tnum): every digit one width, for numbers that change in place

Rich text and spans

ui.rich takes its runs as array entries, and any property for the paragraph (wrap, text_size, color… are the runs’ defaults):

Entry
ui.span { "text", … }a styled run
a string or functiona run in the paragraph’s style (templates work)
a table, { "text", bold = true }a span’s options in place: the same as ui.span { … }
Span optionTypeDefault
[1]Text""the run’s text: a template or a function follows state
colorColorthe paragraph’s
bgColornonebehind the run (a highlight)
text_sizenumberthe paragraph’s
weightnumberthe paragraph’sbold = true is 700
bold, italic, underline, strikebooleanfalse
font"mono"the paragraph’s
paddingnumber or { across, down }0room around the run: across, before and after it (it takes space in the line); with bg, the background hugs the text plus this instead of filling the line, an inline code chip
radiusnumber0the background’s corner radius
underline_offsetnumbera tenth of the text sizehow far below the baseline the underline sits (CSS’s text-underline-offset)
on_clickClickHandler | Handler | Actionnonea click on the run (a link, underlined), told the keys held ({ shift, ctrl, alt, meta }); a middle click passes as a ⌘-click (Control elsewhere), as a browser opens a background tab
hrefstringnonewhere the run links ("https://…"): a link, underlined, with the pointing hand; a click (after on_click’s) opens it with the system’s handler if it is a web or mail link (http:, https:, mailto:; other schemes don’t open), and its context menu has Open Link (those only) and Copy Link
hover{ underline: boolean?, color: Color? }nonea link’s look under the pointer (CSS’s a:hover); with it, the link isn’t underlined at rest unless underline says so
icon{ src?, code?, size?, gap?, tint? }nonean icon before the text, in the line (a file chip’s file type): src (a file, https://…) or code (SVG markup), size square (12), gap from the text (4), centred on the line; tint a colour, or "currentColor" for the span’s

Example

local count = ui.state("count", 0)
local name = ui.state("name", "Ada")
return ui.column {
	p = 24,
	gap = 10,
	w = 360,
	ui.text { "Hello, {name}", text_size = 22, bold = true },
	ui.text {
		function()
			return if count:get() == 1 then "1 message" else `{count:get()} messages`
		end,
		color = "#8b95a5",
	},
	ui.text {
		"A long line wraps at the column's width instead of running out of it.",
		wrap = true,
	},
	ui.rich {
		wrap = true,
		"Press ",
		ui.span { "Add", bold = true },
		" or edit ",
		ui.span { "app.luau", font = "mono", bg = "#ffffff14", padding = { 4, 0.5 }, radius = 3 },
		" ",
		ui.span { "({count} so far)", color = "#5ea5ff", underline = true, on_click = ui.add(count) },
	},
}

Markdown

ui.markdown_view(props: MarkdownViewProps) -> Node

ui.markdown_view { text } draws markdown: paragraphs and headings of styled runs (bold, italic, struck, code, links), lists, quotes, rules, tables, and code blocks with their language and a Copy button, in the theme’s colours and sizes ($surface.sunken, $border.subtle, $text.tertiary, $font.size.*), selectable as prose is. Its text is a string, a template, a state’s ("{reply}") or a function. As the text changes it parses only what changed and builds only the blocks whose value changed, so a reply streaming in grows by its last block, and every block before it keeps its nodes, layout and paint. Options: breaks (single newlines break lines), paths (repository paths are files), copy = false (no Copy buttons); the rest are a column’s styles (gap between blocks is 10).

local reply = ui.state("reply", "Some **bold** text, then code:\n\n```lua\nprint(1)\n```")
return ui.markdown_view { "{reply}", w = 520, text_size = 15 }

To draw blocks your own way, take them from ui.markdown:

ui.markdown(text: string, opts: MarkdownOptions?) -> { MarkdownBlock }

ui.markdown parses markdown (CommonMark with tables, strikethrough and task lists; raw HTML dropped) into blocks for a script to draw its own way. A block has t: p (s: runs), h (l: level, s), code (lang, text), ul and ol (start, items: each item a list of blocks), quote (b: blocks), hr, table (head: cells, rows: rows of cells, a cell being runs) and img (src, alt). A tight list item’s text is a p with tight. Quotes and lists nest 64 levels at most (a list level counts two); deeper ones show as the deepest level’s blocks. A run is { x = text, b?, i?, c? (code), s? (strike), a? (link), f? (a file) }. The types say so: a MarkdownBlock is a union by t, so if b.t == "code" then … b.lang … end is checked, and a run is a MarkdownRun.

OptionTypeDefault
keystringnonenames a text that grows (a reply streaming in): each call parses only its new end, and its earlier blocks are the very tables of the last call, so a ui.each over them builds only the blocks that changed. A marker left open at the end (**bold, a link’s address half there) shows closed until the closer arrives, so the text does not restyle and reflow when it does; such a link’s a is "zgx:pending-link" (style it, but it goes nowhere yet)
breaksbooleanfalsesingle newlines break lines, as people’s messages show them
pathsbooleanfalserepository paths (src/main.rs:12), code spans that are a path whole and relative links that read as one are files: runs with f

Without a key, the same text gives the same blocks. Rust hosts parse with zgx_script::markdown::parse and stream with zgx_script::markdown::Incremental, which take a code block hook too (coloured lines, say).

local reply = ui.state("reply", "A **streaming** reply.\n\n- one\n- two")

local function runs(list: { MarkdownRun }): Node
	local props: RichProps = { wrap = true }
	for _, r in list do
		table.insert(props, ui.span { r.x, literal = true, weight = if r.b then 600 else nil })
	end
	return ui.rich(props)
end

local blocks = ui.computed(function()
	return ui.markdown(reply:get(), { key = "reply" })
end)

return ui.column {
	gap = 8,
	selectable = true,
	ui.each(blocks, function(b: MarkdownBlock)
		if b.t == "ul" then
			local items: { Child } = {}
			for _, item in b.items do
				local first = item[1]
				if first and first.t == "p" then
					table.insert(items, runs(first.s))
				end
			end
			return ui.column { gap = 4, items }
		elseif b.t == "p" or b.t == "h" then
			return runs(b.s)
		end
		return ui.div {}
	end),
}

Selectable text

Text is not selectable unless asked, as in native apps. selectable = true on any node makes the text in it and everything it holds selectable as a web page’s is: one selection runs across its texts, paragraphs, list rows and spans in document order. Drag to select; a double press selects a word and a triple press a paragraph, and dragging after either extends by words or paragraphs; Shift extends; ⌘A / Ctrl+A selects it all and ⌘C / Ctrl+C copies plain text (a tab between texts side by side, a blank line between blocks set apart). A secondary press on selected text offers Copy and Select All. Links and buttons inside still click; a drag that starts on one selects. Holding a drag past a scroll viewport’s edge scrolls it, and a selection in ui.list’s rows outlives the rows it recycles. selectable = false keeps a part out (a toolbar, a timestamp). ui.markdown_view is selectable already; drawing ui.markdown’s blocks yourself, put selectable = true on the node you draw them in, as above. crates/zgx-script/examples/selectable draws a whole document (cargo run -p zgx-script --example selectable). The highlight follows CSS’s ::selection. selection_color and selected_text_color on the selectable node set its background and the selected glyphs’ colour (a colour, a "$token", a state or a function). The tokens $selection, $selection.inactive (window at the back), $selection.text and $selection.text.inactive (transparent, the default, keeps the glyphs’ colours) and $selection.radius style it per subtree through theme: ui.column { selectable = true, theme = { ["selection.text"] = "#ffffff", ["selection.radius"] = 3 }, … }. Prose the examples ship (the agent trace, the streaming transcript, the notes preview) is selectable, as a browser’s would be; controls and chrome are not.

Gotchas

  • Text from data is a template too: a commit message with {id} in it looks for state named id (an error if there is none), and {{ shows as {. Pass literal = true (on ui.text, ui.rich or a span) for text the script didn’t write.
  • A template names state by its key, not by its Luau variable: "{count}" shows ui.state("count", …) whatever the local is called (a component’s ui.use_state has no key: give ui.text the state itself, ui.text { count }), and "{state.count}" is state state’s field count, not a module’s state. A name no state has is an error when the text is built.
  • Text does not wrap by default, and a row shrinks it no narrower than it is (its min-content, unwrapped): wrap = true (in a box with a width) lets it wrap down to its longest word, or min_w = 0, truncate = true ends it with ”…” in a row.
  • Span options are plain values, read once: a state or function there is not followed (its value at build time is used). Only the run’s text follows state. To restyle a run, build the paragraph in a function, or use two texts.
  • A span’s font only knows "mono"; any other family is ignored (ui.text’s font takes names).
  • A span is used once: putting the same ui.span in two paragraphs is an error. Make a new one each time.
  • ui.text and ui.rich take no children; extra array entries in ui.text are an error.
  • A function’s text is called again for each change it read; prefer a template for plain state, which costs no script call.
Edit on GitHub