Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

KindPageBoxChildren
rectrect✓Stacked
row, columnrow and column✓Flow
listlistFlow, from data
texttextLeaf
iconiconLeaf
imageimageLeaf
capturecaptureLeaf
pathpathLeaf
shadershaderLeaf
textfieldtextfieldLeaf

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

RuleDetail
TypesA 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
SignalsA 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
nilA signal reading nil leaves its property absent, at its default. Capabilities read nil until their first push, so binding one never fails layout
NumbersFinite. A value outside a property’s range is an error, not a clamp
Colours"#RRGGBB" or "#RRGGBBAA" (colours)
StringsCapped at 64 KB
Arrayschildren, 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
TablesA 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 booleansEvery 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.

KindChildrenMain axis
rowFlow left to rightHorizontal
columnFlow top to bottomVertical
listFlow, generated from itemsdirection: vertical by default
rect, every surfaceStack: each child gets the whole content box and aligns in it on its own. Later children paint over earlier onesNone
text, icon, image, capture, path, shader, textfieldNone (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.

ValueSize
OmittedContent: text and icon measure themselves, containers wrap their children, other leaves are 0
NumberPixels, [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

PropertyMeaning
paddingInside the node’s box, around its children or text
marginOutside the box; part of the room the node takes in its parent
spacingGap 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:

Wherealign_h / align_v does
A child in a stacking parentPlaces the child in the parent’s content box on that axis
A child in a flow, across its main axisPlaces the child across the row’s height or the column’s width
A child in a flow, along the main axisIgnored: 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).

PropertyTypeDefaultBehaviour
widthLength|Bound, [0, 8192]ContentSee sizes
heightLength|Bound, [0, 8192]ContentSee sizes
max_widthnumber|Bound, [0, 8192]NonePixel ceiling, CSS max-width. Content past it overflows; a scroll on the same node scrolls it (sizes)
max_heightnumber|Bound, [0, 8192]NonePixel ceiling, as max_width
min_widthnumber|Bound, [0, 8192]NonePixel floor, CSS min-width; wins over a lower max_width
min_heightnumber|Bound, [0, 8192]NonePixel floor, as min_width
marginnumber|Edges|Bound0Outside the box; part of the room the node takes in its parent. A number sets all four edges; not range-checked (spacing)
paddingnumber|Edges|Bound, [0, 8192]0Inside 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
visibleboolean|Boundtruefalse removes the node from layout, paint and spacing and freezes its subtree (showing and hiding)
opacitynumber|Bound, [0, 1]1Multiplied down the tree. At 0 the node still takes space and input
znumber|Bound0Sibling paint and hit order. Higher paints later and hits first; ties keep declaration order. Layout and focus ignore it; animate refuses it
scalenumber|Axes|Bound, [0, 64]1About origin; a missing axis is 1. Paint only: layout and geometry see the unscaled box; hit-testing follows the painted one
rotatenumber|Bound, [-8192, 8192]0Degrees clockwise about origin. Paint only
translateAxes|Bound, [-8192, 8192]{ x = 0, y = 0 }Pixel offset per axis, a missing one 0, applied after scale and rotate. Paint only
originAxes|Bound, [0, 1]{ x = 0.5, y = 0.5 }Pivot for scale and rotate as box fractions; a missing axis is 0.5
shadowsShadowLayer[]|BoundNoneDrop 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
effectEffect|BoundNonePixel 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
animateAnimations|BoundNonePer-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
settledBoundNoneA settled(name) signal the engine sets to whether every animate tween on this node is at rest (settled)
idstringNoneUnique among siblings; matches this node across passes (identity). Never a signal
accessible_namestring|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_targetFocusHandleNoneA 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
autofocusboolean|BoundfalseTakes 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_ringboolean|Boundtruefalse keeps the engine’s focus outline off this node
focusedBoundNoneA focused(name) signal the engine sets while this node or its children hold control focus
focus_visibleBoundNoneA focus_visible(name) signal the engine sets while the focus outline would show on this node or its children, even with focus_ring = false
hoverBoundNoneA hover(name) signal the engine sets while the pointer is over this node or its children (hover)
pointerBoundNoneA 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)
geometryBoundNoneA geometry(name) signal the pass writes this node’s surface-local rect into (geometry)
cursorCursor|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 arrowOne of the cursor names. The innermost node under the pointer that sets one wins
on_hoverfun(hovered: boolean)NoneCalled 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_keyfun(key: KeyPress): boolean?NoneA 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_releasefun(key: KeyRelease): boolean?NoneA key release, routed like on_key; true stops it (key handlers). Needs accessible_name to take focus
on_clickfun(rect: Rect, button: "left"|"right"|"middle"|"back"|"forward", pointer: { x: number, y: number }, modifiers: Modifiers)NoneOn 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_clickfun(rect: Rect, button: "left"|"right"|"middle"|"back"|"forward", pointer: { x: number, y: number }, modifiers: Modifiers)NoneAfter 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_pressfun(rect: Rect, button: "left"|"right"|"middle"|"back"|"forward", pointer: { x: number, y: number }, modifiers: Modifiers)NoneOn 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_dragfun(rect: Rect, pointer: { x: number, y: number }, phase: "start"|"move"|"end", modifiers: Modifiers, velocity: { x: number, y: number })NoneLeft-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_wheelfun(rect: Rect, steps: number, modifiers: Modifiers)NoneVertical wheel in notches, positive away from the user, fractional on touchpads. The innermost handler or scroll container wins; on one node, the scroll
submits_secureboolean|BoundfalseA click also submits the armed secure field, like Enter. Works without on_click and runs before it
hittableboolean|BoundInherited; true at the rootfalse 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.

GroupNames
Generaldefault, context-menu, help, pointer, progress, wait
Selectioncell, crosshair, text, vertical-text
Drag and dropalias, copy, move, no-drop, not-allowed, grab, grabbing
Resizee-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
Otherall-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).

ChildMatches
With an idThe old sibling with the same id, wherever it moved. No such sibling: a new node
Without an idThe old id-less siblings, in order
Either, with a different kindNothing: 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 image with retain or transition, a capture, a textfield.
  • 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…

TaskAnswer
Split a bar into left, centre and rightThe bar at the top: two "fill" rows around a content-sized middle
Centre somethingrect: centre something
Put a badge over an iconrect: a stacking parent with the badge aligned to a corner
Push items to the far end of a rowrow and column: a "fill" spacer
Show a progress barThe meter: a percentage-width rect in a "fill" track
Truncate long textwidth (or "fill") plus elide = "end"; see text
Make something clickableGive it on_click; to make a whole row clickable, put on_click on the row (pointer)
Build rows from data, or a gridlist
Scroll a long listlist: scroll a long list
Show an app’s iconicon
Round an image’s cornersimage: round an image’s corners
Switch between tabsSwitching views with ids
Toggle a section in placevisible = signal; see showing and hiding
Read where a node ended upgeometry = geometry("name") (geometry)
Press feedback that does not re-lay outTween scale or translate (animation)

Gotchas

TrapFix
width = "content" is refusedOmit the property; content size is the default
An image, capture, path, shader or textfield does not appearThey have no intrinsic width. Give width (and height, except for a textfield), or "fill" in a sized parent
A "fill" child is 0 wideIts parent is content-sized along that axis, or fixed siblings already overflow. Size the parent
"50%" resolves to 0The parent has no definite size on that axis
Items in a rect overlapThey stack their children; put a row inside for side by side
A switched view snaps in without its entry or exit animationSame kind at the same position is reused, not replaced. Give each view its own id
duplicate id errorSibling ids, and list keys, must be unique
on_click = cond and fn raises expected a functionA false cond yields false: write cond and fn or nil
children = { a, cond and b, c } raises children[2]: expected a node tableA 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 updateA 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 clicksUse 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.