A small Lua wrapper module around the single‑header layout engine Clay (written in C).
This is not an FFI binding; it is a compiled Lua C module that exposes a Lua‑friendly API for declaring elements, running layout, and iterating the resulting render commands.
- Lightweight Lua API mirroring Clay’s core functionality (context/init, element declaration, scrolling, pointer state).
- Helpers for sizing, padding, IDs, and text configuration to keep layout declarations concise.
- A layout end iterator that yields typed
ClayCommandobjects per render command (rectangles, text, images, borders, scissor start/end, custom).
- LuaJIT (recommended) or Lua 5.1+
- A renderer that consumes the iterated render commands to actually draw
- Build the C wrapper as a shared library so
require("clay")works.
# Example (adjust for your OS / compiler)
cc -O2 -shared -fPIC -I. -o clay.so clay_lua_bindings.clocal clay = require("clay")
-- Initialize Clay (arena capacity, width, height)
local arena_mem, ctx = clay.initialize(clay.minMemorySize(), 1280, 720)Call clay.setLayoutDimensions(width, height), clay.setPointerState({x,y}, isDown), and clay.updateScrollContainers(enableDrag, {x=dx,y=dy}, dt) once per frame as appropriate.
A typical frame using Clay looks like this:
- Set layout dimensions (window size)
- Set pointer state (mouse)
- Update scroll containers (if used)
- Begin layout
- Build UI tree (builders)
- End layout and iterate render commands
clay.setLayoutDimensions(w, h)
clay.setPointerState(mx, my, mouse_down)
clay.updateScrollContainers(true, scroll_dx, scroll_dy, dt)
clay.beginLayout()
clay.element("Root")
:width(clay.SIZING_FIXED, w)
:height(clay.SIZING_FIXED, h)
:children(function()
clay.text("Hello World")
:fontSize(16)
:done()
end)
for cmd in clay.endLayoutIter() do
-- renderer consumes cmd
end-- Common usage
local e = clay.element("Name" [, index [, isLocal]])
-- Advanced: explicit id table
local eid = clay.id("Name", index, isLocal)
local e = clay.element(eid)Parameters
Name(string): base name used for hashing the element idindex(number, optional): for repeated elements (e.g. list rows)isLocal(boolean, optional): if true, the id is hashed relative to the parent element id
Calling clay.element(...) immediately opens an element in Clay.
You must close it using one of the following:
:children(function() ... end)— runs children and auto-closes:close()— closes immediately (leaf element)
If an element is garbage-collected while still open, the builder’s __gc will attempt a best-effort close to keep Clay’s internal stack consistent. This is a safety net, not a control flow mechanism.
All builder methods return self so they can be chained.
clay.LEFT_TO_RIGHTclay.TOP_TO_BOTTOM
Sets spacing between children.
Horizontal alignment:
clay.ALIGN_X_LEFTclay.ALIGN_X_CENTERclay.ALIGN_X_RIGHT
Vertical alignment:
clay.ALIGN_Y_TOPclay.ALIGN_Y_CENTERclay.ALIGN_Y_BOTTOM
Overloads:
:padding(all)
:padding(x, y)
:padding(left, top, right, bottom)Values are stored internally as unsigned integers.
Sizing types:
clay.SIZING_FIXEDclay.SIZING_GROWclay.SIZING_FITclay.SIZING_PERCENT
Usage examples:
:width(clay.SIZING_FIXED, 220)
:height(clay.SIZING_GROW)
:width(clay.SIZING_PERCENT, 100)
:width(clay.SIZING_GROW, 50, 400)Argument rules:
- FIXED:
(size) - PERCENT:
(percent) - GROW / FIT:
- no args → min=0, max=0
- one arg → min
- two args → min, max
RGBA values (floats).
Enables scissor clipping.
Sets the clip child offset explicitly.
If clipping is enabled but childOffset is never set, the builder defaults to Clay_GetScrollOffset() at configuration time.
The following methods accept either lightuserdata or any Lua value:
:imageData(value):customData(value):userData(value)
If a non-lightuserdata Lua value is passed, it is stored in the Lua registry and transferred to Clay as a tagged pointer.
Lifetime rules:
- After configuration, the builder detaches these pointers so its
__gcdoes not free them - The registry reference is released when the render command accessor is called
- These accessors are one-shot per render command
Maps directly to decl.floating.*:
:attachTo(mode):attachPoints(elementPoint, parentPoint):offset(x, y):expand(w, h):parentId(idOrNumber):zIndex(z):pointerCaptureMode(mode):clipTo(mode)
Applies configuration, executes fn(), then closes the element.
Errors are wrapped with debug.traceback and the element is closed before rethrowing.
Applies configuration (if needed) and closes immediately.
clay.text("Hello")
:fontSize(16)
:textColor(255,255,255,255)
:done()
-- Callback form (auto-emits)
clay.text("Hello", function(t)
t:fontSize(16)
t:textAlignment(clay.TEXT_ALIGN_CENTER)
end)fontId = 1fontSize = 16textColor = {255,255,255,255}wrapMode = clay.TEXT_WRAP_WORDStextAlignment = clay.TEXT_ALIGN_LEFTletterSpacing = 0lineHeight = 0
:fontId(id):fontSize(px):textColor(r,g,b[,a]):wrapMode(mode):textAlignment(align):letterSpacing(px):lineHeight(px):userData(value):close()/:done()
Text builders emit exactly once.
After layout:
for cmd in clay.endLayoutIter() do
local t = cmd:type()
if t == clay.RENDER_RECTANGLE then
local x,y,w,h = cmd:bounds()
local r,g,b,a = cmd:color()
elseif t == clay.RENDER_TEXT then
local text, fontId, fontSize = cmd:text()
elseif t == clay.RENDER_IMAGE then
local data = cmd:imageData() -- one-shot
elseif t == clay.RENDER_CUSTOM then
local data = cmd:customData() -- one-shot
end
endIf the payload was a Lua value (not lightuserdata), calling the accessor releases the registry reference and clears the pointer.
clay.createElement(id, decl, function()
clay.createTextElement("Hello", {fontSize=16})
end)clay.element("MyElement")
:children(function()
clay.text("Hello")
:fontSize(16)
:done()
end)clay.element("Spacer")
:width(clay.SIZING_GROW)
:height(clay.SIZING_FIXED, 2)
:close()- Prefer
:children()for elements with children - Prefer
:close()for leaf elements - Do not rely on
__gcto close elements - Treat
cmd:imageData(),cmd:customData(), andcmd:userData()as one-shot when using Lua values - Use
clay.id(name, index)for interactive or queryable elements - Use
isLocal=truein reusable components to avoid id collisions
for cmd in clay.endLayoutIter() do ... end
- Finalizes the current frame’s layout by calling Clay’s internal
Clay_EndLayout(). - Returns a closure iterator over a stable array of render commands for that frame.
- Each iteration yields a
ClayCommanduserdata with methods to inspect the command.
- It keeps Lua code ergonomic and avoids copying the command array.
- It lets you write idiomatic
for ... inloops to drive your renderer.
- Call order: Always
beginLayout()→ declare elements →endLayoutIter()(then iterate). You cannot add more elements after callingendLayoutIter()for that frame. - Lifetime: The yielded
ClayCommandobjects are only valid during the same frame. Don’t store them across frames; if you must cache, copy the primitive fields you need. - One iterator per frame: Call
endLayoutIter()once per frame to consume the results. If you need multiple passes, store the commands you care about in your own structures during the first iteration. - Culling & scissor: If you enable culling (
clay.setCullingEnabled(true)), expect fewer commands. Scissor commands (clip start/end) will appear and should be respected by your renderer.
These methods exist on each yielded cmd (use method syntax cmd:method()):
-
cmd:type() -> integer
Compare with exported constants:
RENDER_RECTANGLE,RENDER_BORDER,RENDER_TEXT,RENDER_IMAGE,RENDER_SCISSOR_START,RENDER_SCISSOR_END,RENDER_CUSTOM. -
cmd:id() -> integer
The element’s unique ID for the frame. -
cmd:bounds() -> x, y, width, height
Bounding box in layout coordinates. -
cmd:zIndex() -> integer
Higher values draw on top. -
cmd:color() -> r, g, b, a- Rectangle/Text/Image only. Returns command-appropriate color (text color for text, background for rectangles, image tint if applicable). Returns nothing for types without a color.
-
cmd:text() -> string, fontId, fontSize, letterSpacing, lineHeight- Only on
RENDER_TEXTcommands.
- Only on
-
cmd:cornerRadius() -> tl, tr, bl, br- Only on
RENDER_RECTANGLEwith rounded corners.
- Only on
-
cmd:borderWidth() -> left, right, top, bottom- Only on
RENDER_BORDER.
- Only on
-
cmd:imageData() -> lightuserdata- Only on
RENDER_IMAGE; pointer you passed via element config (your renderer should know how to use it).
- Only on
-
cmd:clip() -> horizontal:boolean, vertical:boolean- Only on
RENDER_SCISSOR_START/RENDER_SCISSOR_END.
- Only on
Important: A method that doesn’t apply to the current command type returns nothing (nil in Lua). Always branch on cmd:type() before calling type-specific accessors.
You can attach arbitrary payloads to elements and read them back from the render commands in your draw loop. The wrapper supports two forms:
- Lightuserdata pointer (zero-copy, stays a pointer)
- Any Lua value (table/string/number/function/cdata/etc.) — stored internally as a registry ref and restored on read
When you set a non-lightuserdata Lua value, the wrapper stores it as a Lua registry reference behind the scenes. On the first call to cmd:imageData(), cmd:customData(), or cmd:userData():
- The wrapper restores the original Lua value,
- Unrefs the registry entry to avoid leaks,
- Nulls out the internal pointer for that command.
So a second call in the same frame will return nil. If you need the value in multiple places, cache it in a local variable on first read.
If you pass a lightuserdata pointer, the wrapper returns that pointer each time (it doesn’t consume it), and you won’t hit the one-shot behavior.
- Render types:
RENDER_RECTANGLE,RENDER_BORDER,RENDER_TEXT,RENDER_IMAGE,RENDER_SCISSOR_START,RENDER_SCISSOR_END,RENDER_CUSTOM. - Layout direction:
LEFT_TO_RIGHT,TOP_TO_BOTTOM. - Alignment:
ALIGN_X_LEFT,ALIGN_X_CENTER,ALIGN_X_RIGHT,ALIGN_Y_TOP,ALIGN_Y_CENTER,ALIGN_Y_BOTTOM. - Text alignment:
TEXT_ALIGN_LEFT,TEXT_ALIGN_CENTER,TEXT_ALIGN_RIGHT. - Text wrap:
TEXT_WRAP_NONE,TEXT_WRAP_WORDS,TEXT_WRAP_NEWLINES. - Sizing kinds:
SIZING_FIT,SIZING_GROW,SIZING_FIXED,SIZING_PERCENT. - Floating attach points: a family of
ATTACH_*/ATTACH_POINT_*constants (see code).
clay.id(label, index?, isLocal?) -> idTable
Use withcreateElementto produce stable IDs.clay.sizingFixed(w),clay.sizingFit(min?, max?),clay.sizingGrow(min?, max?),clay.sizingPercent(p)clay.paddingAll(p),clay.paddingXY(x, y),clay.paddingLTRB(l,t,r,b)
clay.setPointerState({x, y}, pointerDown)clay.hovered() -> booleanclay.pointerOver(id) -> booleanclay.updateScrollContainers(enableDrag, {x, y}, deltaTime)clay.getScrollOffset() -> {x, y}(current open container)clay.getScrollContainerData(id) -> table(dimensions, content, config, position)clay.setScrollOffset(id, x, y)(programmatic scrolling)
Scrollable containers: prefer omitting clip.childOffset so the wrapper wires in the correct per‑element scroll offset automatically.
If initialization fails or Clay reports an error (e.g., arena exhausted, duplicate IDs, invalid percentages), the wrapper uses Clay’s error callbacks and surfaces failures as Lua errors where appropriate.
Same as this repository’s source files (see headers).