Nodes
Nodes are the UI tree inside a surface. Each constructor (row { ... },
text { ... }) takes a property table and returns it tagged with its kind. This page covers layout
and the properties every kind shares; each kind’s page covers what it adds. How a box looks is on
paint, motion on animation, clicks and typing on
input.
A bar with a left group, a centred clock and a right group:
local clock = state("clock", "12:00")
local function workspace(active)
return rect { width = active and 18 or 8, height = 8, radius = 4, background = active and "#89B4FA" or "#45475A" }
end
local function status(name)
return icon { name = name, size = 16, foreground = "#CDD6F4" }
end
local bar = panel {
id = "bar",
layer = "top",
anchor = { top = true, left = true, right = true },
exclusive_zone = 32,
width = "fill",
height = 32,
child = row {
width = "fill",
height = "fill",
padding = { left = 12, right = 12 },
background = "#1E1E2ECC",
children = {
row { width = "fill", align_v = "center", spacing = 6, children = {
workspace(true), workspace(false), workspace(false),
} },
text { content = clock, align_v = "center", font_weight = 700, foreground = "#CDD6F4" },
row { width = "fill", align_h = "end", align_v = "center", spacing = 12, children = {
status("network-wireless-symbolic"), status("audio-volume-high-symbolic"), status("bluetooth-active-symbolic"),
} },
},
},
}
return { bar }
The two side rows are "fill", so they split what the clock leaves equally, and the clock sits at
the exact centre whatever its width. The right row packs its children at its end.
Kinds
Every kind accepts the common properties. Box kinds also accept the
box properties. Any other key raises an error: a typo such as aling_v asks
“did you mean align_v?”, and a key close to nothing lists what the kind accepts. Keys inside a
property table (anchor = { lefft = true }) and choice values (align_h = "cenetr") get the same
suggestion.
| Kind | Page | Box | Children |
|---|---|---|---|
rect | rect | ✓ | Stacked |
row, column | row and column | ✓ | Flow |
list | list | Flow, from data | |
text | text | Leaf | |
icon | icon | Leaf | |
image | image | Leaf | |
capture | capture | Leaf | |
path | path | Leaf | |
shader | shader | Leaf | |
textfield | textfield | Leaf |
The four surface roles (panel, window, popup, lock) are node kinds too: they take the common
and box properties but margin, align_h and align_v, and stack their one child (surfaces).
Values
| Rule | Detail |
|---|---|
| Types | A property table’s Type column is the editor stubs’ LuaCATS type. Bound means it also takes a signal; Length is a size; Edges is { top, right, bottom, left } with missing edges 0; Axes is { x, y } with a missing axis at the property’s default; Color is a colour; Animations is per-property tweens, keyed by the node’s own properties (RectAnimations on a rect). A range after the type is checked |
| Signals | A property whose Type includes Bound takes a signal; id and callbacks do not. hover, pointer, scroll and geometry take the signal handle itself. A table property takes a signal for any value inside it |
nil | A signal reading nil leaves its property absent, at its default. Capabilities read nil until their first push, so binding one never fails layout |
| Numbers | Finite. A value outside a property’s range is an error, not a clamp |
| Colours | "#RRGGBB" or "#RRGGBBAA" (colours) |
| Strings | Capped at 64 KB |
| Arrays | children, list.items and text runs take at most 10000 elements. A nil hole in children is an error; in list.items and runs it ends the array |
| Tables | A table property (padding, anchor, origin, transition, an animate entry, a run, …) refuses a key it does not take, so { topp = 4 } names topp |
| Callbacks and booleans | Every on_* takes only a function, and every boolean property (visible, submits_secure, …) only true or false. on_click = "x" or submits_secure = 1 is an error. For a conditional handler write cond and fn or nil: false is refused too |
Layout model
A pass is one resolve of a surface: the engine re-runs the node tree’s signals, re-lays it out and repaints. It happens after a signal the surface reads changes (signals). Layout is flexbox, solved by taffy on every pass. Each container either flows its children along one axis or stacks them on top of each other.
| Kind | Children | Main axis |
|---|---|---|
row | Flow left to right | Horizontal |
column | Flow top to bottom | Vertical |
list | Flow, generated from items | direction: vertical by default |
rect, every surface | Stack: each child gets the whole content box and aligns in it on its own. Later children paint over earlier ones | None |
text, icon, image, capture, path, shader, textfield | None (leaves) | None |
A stacking parent’s content size is the union of its children, so a rect is how you layer a badge
over an icon or a label over an image. A parent with its own size gives every child its content
box, even a child larger than it.
Of the leaves, only text and icon measure themselves. image, capture, path, shader
and textfield have no intrinsic width: without width they are 0 wide and draw nothing, and all
but textfield need height too. A textfield defaults to one line of font_size tall.
Sizes
width and height take the same values.
| Value | Size |
|---|---|
| Omitted | Content: text and icon measure themselves, containers wrap their children, other leaves are 0 |
| Number | Pixels, [0, 8192] |
"fill" | Along the parent’s main axis: an equal share of the space the fixed and content-sized siblings leave. Across it, or in a stacking parent: the whole slot, whatever align_h/align_v say |
"NN%" ("50%", "12.5%") | A fraction of the parent’s content box (inside its padding). It needs a parent with a definite size on that axis |
There is no "content" literal; omit the property instead.
Children never shrink. Fixed and content-sized children that overflow a row keep their sizes and
overflow it: they paint past a parent with no clip and are cut only when it sets one (a scroll
viewport and a surface cut by default), and
"fill" siblings get 0. A "fill" child along the main axis of a content-sized parent also gets 0:
there is no remainder to share. Across the axis, "fill" in a content-sized parent takes the
largest sibling’s size.
min_width, min_height, max_width and max_height are pixels [0, 8192] (not "fill" or
percents). They clamp every size, content, fixed and "fill" alike, as in CSS: a "fill" capped
by max_width leaves the rest to its "fill" siblings. A floor above a ceiling wins. Content past
a ceiling overflows; a scroll on the same node scrolls it (scroll).
Spacing, padding and margin
| Property | Meaning |
|---|---|
padding | Inside the node’s box, around its children or text |
margin | Outside the box; part of the room the node takes in its parent |
spacing | Gap between visible children of a row, column or list. Negative values overlap them |
padding and margin take a number for all four edges or { top, right, bottom, left } with
missing edges 0. Negative margin is accepted; each padding edge must be within [0, 8192]. A hidden child adds no gap.
Alignment
align_h and align_v take "start", "center", "end" or "stretch", default "start", and
act by axis:
| Where | align_h / align_v does |
|---|---|
| A child in a stacking parent | Places the child in the parent’s content box on that axis |
| A child in a flow, across its main axis | Places the child across the row’s height or the column’s width |
| A child in a flow, along the main axis | Ignored: the parent packs that axis |
A row’s own align_h, a column’s own align_v (a list’s along its direction) | Packs its children along the main axis ("stretch" packs like "start"). The same value also places the container itself in its parent |
"stretch" across an axis fills the slot and overrides a fixed size on that axis. To space items
out along a row, use "fill" children as spacers.
text_align on text and textfield is separate: it places lines
inside the node’s own box, and matters only when that box is wider than the text.
Common properties
The pointer handlers on_click, on_double_click, on_drag, on_wheel and submits_secure are here, so any node takes
clicks: the innermost node under the pointer with a handler for the event wins, and a node without
one lets the event through to the node around it (pointer).
| Property | Type | Default | Behaviour |
|---|---|---|---|
width | Length|Bound, [0, 8192] | Content | See sizes |
height | Length|Bound, [0, 8192] | Content | See sizes |
max_width | number|Bound, [0, 8192] | None | Pixel ceiling, CSS max-width. Content past it overflows; a scroll on the same node scrolls it (sizes) |
max_height | number|Bound, [0, 8192] | None | Pixel ceiling, as max_width |
min_width | number|Bound, [0, 8192] | None | Pixel floor, CSS min-width; wins over a lower max_width |
min_height | number|Bound, [0, 8192] | None | Pixel floor, as min_width |
margin | number|Edges|Bound | 0 | Outside the box; part of the room the node takes in its parent. A number sets all four edges; not range-checked (spacing) |
padding | number|Edges|Bound, [0, 8192] | 0 | Inside the box, around its children or text. A number sets all four edges; each edge is within [0, 8192] (spacing) |
align_h | "start"|"center"|"end"|"stretch"|Bound | "start" | See alignment |
align_v | "start"|"center"|"end"|"stretch"|Bound | "start" | See alignment |
visible | boolean|Bound | true | false removes the node from layout, paint and spacing and freezes its subtree (showing and hiding) |
opacity | number|Bound, [0, 1] | 1 | Multiplied down the tree. At 0 the node still takes space and input |
z | number|Bound | 0 | Sibling paint and hit order. Higher paints later and hits first; ties keep declaration order. Layout and focus ignore it; animate refuses it |
scale | number|Axes|Bound, [0, 64] | 1 | About origin; a missing axis is 1. Paint only: layout and geometry see the unscaled box; hit-testing follows the painted one |
rotate | number|Bound, [-8192, 8192] | 0 | Degrees clockwise about origin. Paint only |
translate | Axes|Bound, [-8192, 8192] | { x = 0, y = 0 } | Pixel offset per axis, a missing one 0, applied after scale and rotate. Paint only |
origin | Axes|Bound, [0, 1] | { x = 0.5, y = 0.5 } | Pivot for scale and rotate as box fractions; a missing axis is 0.5 |
shadows | ShadowLayer[]|Bound | None | Drop shadows, the first on top (shadows). Each layer is { color, blur, offset, spread, inset, blend }; at most 16. A layer draws when alpha > 0 and blur, offset or spread is set |
effect | Effect|Bound | None | Pixel filters: blur (sigma in px, [0, 8192]) and the colour filters saturate, brightness, contrast ([0, 8], 1 is off), at the top level and in backdrop (whose mask fades the glass out; see Progressive blur), and a shader over the node’s subtree or its backdrop; see Blurs and Shader effects. backdrop and input = "backdrop" or "desktop" are for box kinds only |
blend | "normal"|"multiply"|"screen"|"overlay"|"darken"|"lighten"|"color_dodge"|"color_burn"|"hard_light"|"soft_light"|"difference"|"exclusion"|"hue"|"saturation"|"color"|"luminosity"|"plus_lighter"|"plus_darker"|Bound | "normal" | CSS mix-blend-mode onto what this surface painted under the node; see Blend modes |
animate | Animations|Bound | None | Per-property tweens, parent-relative layout move and an exit block (animation). An ancestor that shifts needs its own move. Only a node already on screen animates, unless a property entry has from |
settled | Bound | None | A settled(name) signal the engine sets to whether every animate tween on this node is at rest (settled) |
id | string | None | Unique among siblings; matches this node across passes (identity). Never a signal |
accessible_name | string|Bound | "" | Spoken name for a control. A node with on_click, submits_secure, on_key or on_key_release becomes keyboard focusable when this is set. Give each textfield a name for screen readers |
focus_target | FocusHandle | None | A focus_target(name) handle. :request() from an on_click, an on_key, or an edit typed or committed into a field (on_change, on_submit, on_cancel) moves keyboard focus here, if this node is a visible focusable control on that callback’s keyboard-focused surface or a popup under it. A request from a key callback shows the focus outline, one from a click does not. Inert on a node that takes no focus. Any other value fails the pass |
autofocus | boolean|Bound | false | Takes keyboard focus when the surface gets the keyboard or this node appears; the first in document order wins, field or control. A plain textfield arms under existing control focus unless a field is typing or a press just left it; its draft resets to initial_text ("" when unset) and on_change is called with it. Any other control arms only while no control holds focus, and once: it never takes focus back after the user leaves it. Inert on a node that takes no focus |
focus_ring | boolean|Bound | true | false keeps the engine’s focus outline off this node |
focused | Bound | None | A focused(name) signal the engine sets while this node or its children hold control focus |
focus_visible | Bound | None | A focus_visible(name) signal the engine sets while the focus outline would show on this node or its children, even with focus_ring = false |
hover | Bound | None | A hover(name) signal the engine sets while the pointer is over this node or its children (hover) |
pointer | Bound | None | A pointer(name) signal the engine sets to the pointer’s node-local { x, y } while it is over this node or its children (pointer position) |
geometry | Bound | None | A geometry(name) signal the pass writes this node’s surface-local rect into (geometry) |
cursor | Cursor|Bound | "pointer" on a node with on_click, on_double_click, on_press, on_drag, on_wheel or submits_secure and on a link, "text" on a textfield, else the arrow | One of the cursor names. The innermost node under the pointer that sets one wins |
on_hover | fun(hovered: boolean) | None | Called on each hover edge from pointer Enter, Motion or Leave; layout moving the node under or off a still pointer does not call it, and the next motion does. Refused without hover on the same node |
on_key | fun(key: KeyPress): boolean? | None | A key press or repeat, from the focused node up through its ancestors to the surface; true stops it (key handlers). Needs accessible_name to take focus |
on_key_release | fun(key: KeyRelease): boolean? | None | A key release, routed like on_key; true stops it (key handlers). Needs accessible_name to take focus |
on_click | fun(rect: Rect, button: "left"|"right"|"middle"|"back"|"forward", pointer: { x: number, y: number }, modifiers: Modifiers) | None | On release over the same node that was pressed, with the same mouse button. rect is the node’s surface-local box, before transforms. pointer is node-local and unclamped like on_drag’s; Enter, Space and screen-reader activation report the node’s centre. modifiers is the keyboard’s Modifiers at that moment, also for Enter and Space; every handler below takes it after its own arguments (only on_drag’s velocity follows it), and it reads all false while none of this shell’s surfaces has keyboard focus. A left press on a textfield goes to the field instead; its other presses call its own on_click, never an ancestor’s, and a masked field calls none |
on_double_click | fun(rect: Rect, button: "left"|"right"|"middle"|"back"|"forward", pointer: { x: number, y: number }, modifiers: Modifiers) | None | After on_click, on the release completing a second click on the same node with the same button, within 400 ms and 4 px of the first click’s release; the node may move or resize in between. The innermost node with on_double_click takes it, independently of on_click’s target, so single clicks on a node with only this handler still reach an ancestor’s on_click, which fires for both clicks. A third click starts a new pair. Arguments as on_click’s. A textfield doubles only on non-left clicks, its own handler only. Enter, Space and screen-reader activation never double-click, and a node with only this handler takes no keyboard focus |
on_press | fun(rect: Rect, button: "left"|"right"|"middle"|"back"|"forward", pointer: { x: number, y: number }, modifiers: Modifiers) | None | On press of any mouse button, before on_click and before the release. rect and pointer are as in on_click. The only place toplevel(id) can move, resize or open the window menu, since compositors honour those for the press serial; an on_drag "start" works too. A textfield calls only its own, and only for non-left buttons; a left press edits the field and a masked field calls none |
on_drag | fun(rect: Rect, pointer: { x: number, y: number }, phase: "start"|"move"|"end", modifiers: Modifiers, velocity: { x: number, y: number }) | None | Left-button drag. rect is the node’s box at each call, so it follows a node that moves under its own drag. pointer is node-local and unclamped. "start" on press, "end" on release (before on_click) or when the pointer leaves the surface. velocity is the pointer’s { x, y } in surface logical px per second over the last 100 ms of motion, by the compositor’s event clock: { x = 0, y = 0 } on "start" and after 100 ms without motion. A span under 8 ms keeps the last measured value, and an "end" that is not a release (the pointer leaving the surface) reports the last motion’s |
on_wheel | fun(rect: Rect, steps: number, modifiers: Modifiers) | None | Vertical wheel in notches, positive away from the user, fractional on touchpads. The innermost handler or scroll container wins; on one node, the scroll |
submits_secure | boolean|Bound | false | A click also submits the armed secure field, like Enter. Works without on_click and runs before it |
hittable | boolean|Bound | Inherited; true at the root | false lets the pointer through this node and its descendants to what is underneath; inherited, and a descendant’s true takes it back, and this node’s handlers still apply to it (pass-through) |
scale, rotate and translate act like CSS transform: the subtree draws moved, while layout,
siblings and geometry see the untransformed box. A node scaled to 0 takes no input. Tween them for
motion that skips re-layout.
Cursor names
cursor takes the CSS cursor names that the Wayland cursor-shape protocol (wp_cursor_shape_v1)
also uses. Any other string is refused.
| Group | Names |
|---|---|
| General | default, context-menu, help, pointer, progress, wait |
| Selection | cell, crosshair, text, vertical-text |
| Drag and drop | alias, copy, move, no-drop, not-allowed, grab, grabbing |
| Resize | e-resize, n-resize, ne-resize, nw-resize, s-resize, se-resize, sw-resize, w-resize, ew-resize, ns-resize, nesw-resize, nwse-resize, col-resize, row-resize |
| Other | all-scroll, zoom-in, zoom-out |
The compositor draws the shape from its cursor theme.
Box properties
rect, row, column and the four surface roles also take background, radius,
corner_shape, corner_smoothing, border_color, border_width, clip, mask, compositor_blur and
shadow_mode, and effect.backdrop. They are documented on paint. Leaves and list
take none of them: wrap one in a rect for a background, border or rounded clip.
Identity and reconciliation
Each pass walks the declared tree and matches it against the nodes on screen, one parent at a time.
A matched node keeps its state: running tweens, a held image, a capture stream, a text field’s
draft, and its resolved properties until a signal they read is written. An unmatched old node is
removed, after its animate.exit if it has one (exit).
| Child | Matches |
|---|---|
With an id | The old sibling with the same id, wherever it moved. No such sibling: a new node |
Without an id | The old id-less siblings, in order |
| Either, with a different kind | Nothing: the old one is removed and a new one built |
An id is a plain UTF-8 string, unique among its siblings (a duplicate is refused), never a
signal. In a list, key supplies it. Give a node a stable id when:
- Siblings before it come and go. A position shift pairs it with the wrong old node.
- It holds state across changes: an
imagewithretainortransition, acapture, atextfield. - It replaces another node of the same kind. Two switched views that are both id-less
columns match each other: the new view is the old node with new properties, so no exit or entry plays.
Showing, hiding and switching
visible = false takes a node out of layout, paint and input, with no gap. Its subtree stays in
memory, frozen until it shows again. Use it for a
section toggled in place; for views that replace each other, bind the parent’s children
(switching views). opacity = 0 still takes space and
input.
Switching views with ids
Views swapped through a children signal, each with its own id, so the outgoing one fades out
while the incoming one fades in. The parent is a rect, so the two overlap during the swap instead
of stacking. The shot switches tab to "bluetooth":
local tab = state("tab", "wifi")
local function page(name, title, icon_name, items)
local rows = { text { content = title, font_size = 14, font_weight = 700, foreground = "#CDD6F4" } }
for _, item in ipairs(items) do
rows[#rows + 1] = row { spacing = 10, children = {
icon { name = icon_name, size = 16, foreground = "#89B4FA", align_v = "center" },
text { content = item, foreground = "#CDD6F4", align_v = "center" },
} }
end
return column {
id = name, -- a new id per view: the old view leaves and fades instead of being reused
width = "fill",
padding = 14,
spacing = 10,
radius = 12,
background = "#1E1E2E",
opacity = 1, -- `from` needs the property set
animate = { opacity = { duration = 250, from = 0 }, exit = { duration = 250, opacity = 0 } },
children = rows,
}
end
local views = {
wifi = function() return page("wifi", "Wi-Fi", "network-wireless-symbolic", { "Home", "Office 5G", "Cafe Guest" }) end,
bluetooth = function()
return page("bluetooth", "Bluetooth", "bluetooth-active-symbolic", { "Headphones", "Keyboard", "Phone" })
end,
}
local body = rect {
width = 260,
children = tab:map(function(current) return { views[current]() } end),
}
return body
How do I…
| Task | Answer |
|---|---|
| Split a bar into left, centre and right | The bar at the top: two "fill" rows around a content-sized middle |
| Centre something | rect: centre something |
| Put a badge over an icon | rect: a stacking parent with the badge aligned to a corner |
| Push items to the far end of a row | row and column: a "fill" spacer |
| Show a progress bar | The meter: a percentage-width rect in a "fill" track |
| Truncate long text | width (or "fill") plus elide = "end"; see text |
| Make something clickable | Give it on_click; to make a whole row clickable, put on_click on the row (pointer) |
| Build rows from data, or a grid | list |
| Scroll a long list | list: scroll a long list |
| Show an app’s icon | icon |
| Round an image’s corners | image: round an image’s corners |
| Switch between tabs | Switching views with ids |
| Toggle a section in place | visible = signal; see showing and hiding |
| Read where a node ended up | geometry = geometry("name") (geometry) |
| Press feedback that does not re-lay out | Tween scale or translate (animation) |
Gotchas
| Trap | Fix |
|---|---|
width = "content" is refused | Omit the property; content size is the default |
An image, capture, path, shader or textfield does not appear | They have no intrinsic width. Give width (and height, except for a textfield), or "fill" in a sized parent |
A "fill" child is 0 wide | Its parent is content-sized along that axis, or fixed siblings already overflow. Size the parent |
"50%" resolves to 0 | The parent has no definite size on that axis |
Items in a rect overlap | They stack their children; put a row inside for side by side |
| A switched view snaps in without its entry or exit animation | Same kind at the same position is reused, not replaced. Give each view its own id |
duplicate id error | Sibling ids, and list keys, must be unique |
on_click = cond and fn raises expected a function | A false cond yields false: write cond and fn or nil |
children = { a, cond and b, c } raises children[2]: expected a node table | A false or nil entry is a hole. Build the array with table.insert, or a signal of the whole array |
A node table, or a children array, changed in place after the config ran does not update | A node reads each children or child table once and keeps it while it holds that table. Bind the property to a signal, or :set a new table |
opacity = 0 hides a node but it still takes clicks | Use visible = false |
See also: surfaces (where a tree lives), signals (live properties), paint, animation, input, capabilities.
Source: node vocabulary,
layout solver,
pass, reconciliation,
property resolution,
geometry parsers,
transforms,
hit testing and cursors (names from the cursor-icon crate),
check.