On this page
Building interfaces
Tecs provides retained layout without introducing a DOM or a second renderer. A UI node is an entity. tecs.ecs.ChildOf defines both its layout hierarchy and its transform hierarchy, while the existing rectangle, circle, image, sprite, and text producers keep drawing the result through the normal instanced GPU pipeline.

The Compose scene shows the central rule: Taffy sizes retained ECS nodes, while the existing image, rectangle, circle, and text producers draw their leaves.
The boundary is intentionally narrow:
tecs.ui.Stylesupplies layout properties.tecs.ui.Layoutreports the computed box.tecs.ui.Paintoptionally stretches an existing drawing leaf to that box.tecs.ui.Intrinsiclets text, images, and custom leaves contribute their natural size.tecs.ui.Scrolloffsets descendants and assigns the existing renderer clip component.tecs.ui.Scrollbardrives a composed thumb; it is not a special renderer primitive.tecs.ui.Interactionopts a box into hit testing, focus, and activation.
Layout changes only dirty transforms whose computed results changed. Drawing components retain their normal batching, materials, dirty tracking, and GPU instancing.
Start with a complete screen UI
These first two blocks form one complete main.tl. The first block contains only imports and retained entity composition. It creates a full-window root, a panel, an ordinary rounded rectangle behind it, and one intrinsic text leaf. It does not install plugins or register systems.
local tecs <const> = require("tecs")
local ui <const> = tecs.ui
local ChildOf <const> = tecs.ecs.ChildOf
local RelativeTransform2D <const> = tecs.ecs.RelativeTransform2D
local Tint <const> = tecs.gfx.Tint
local Material <const> = tecs.gfx.Material
local Renderable2D <const> = tecs.gfx.Renderable2D
local function spawnInterface(
world: tecs.World, app: tecs.Application, font: tecs.gfx.Font
)
local materials <const> = tecs.gfx.materials
local root <const> = world:spawn(
ui.Style({
width = "100%",
height = "100%",
justifyContent = "center",
alignItems = "center",
padding = "24px",
}),
ui.Root("screen"),
tecs.Transform2D(0, 0, 0, 16)
)
local panel <const> = world:spawn(
ui.Style({
width = 420,
height = 240,
flexDirection = "column",
padding = 20,
gap = 12,
}),
RelativeTransform2D(),
ChildOf(root)
)
world:spawn(
ui.Style({position = "absolute", inset = 0}),
ui.Paint(true),
RelativeTransform2D(0, 0, 0),
Tint(0.035, 0.055, 0.09, 0.96),
Material(materials.id("rounded"), 0.05),
Renderable2D(),
ChildOf(panel)
)
world:spawn(
ui.Style({maxWidth = "100%"}),
ui.Intrinsic("text", {wrap = true}),
RelativeTransform2D(0, 0, 1),
Tint(0.94, 0.97, 1.0, 1.0),
tecs.gfx.Text.new({
text = "This text and its panel are ordinary ECS entities.",
font = font,
size = 16,
}),
ChildOf(panel)
)
endWhen the plugin receives an application, it keeps every screen root's logical size and pixel density synchronized with the window. Percentage dimensions therefore follow a resize without a game observer or per-frame system.
World roots have two explicit contracts. A manually sized root preserves its authored extent for a render target or world-space panel:
ui.Root("world", layoutWidth, layoutHeight, 1, "manual")A camera-sized root follows the active camera and physical viewport. Its layout units are world units, its origin is the camera's top-left world point, and camera zoom or rotation updates its root transform:
ui.Root("world", 0, 0, 1, "camera")Camera2D sizing requires the application form of ui.plugin, because the window supplies the viewport. Screen roots accept "auto" or "manual"; world roots accept "camera" in addition to the manual behavior selected by "auto".
Install from the application plugin
The application plugin configures the UI layer, installs one UI plugin in the world, and registers the system that calls spawnInterface. Passing the application supplies the renderer and folded input. A second table can override clip allocation, the input layer, or wheel distance.
Paste this setup block after spawnInterface. It installs text and UI separately, loads an exact-size alpha font for crisp fixed-size UI text, and spawns the retained tree from Startup.
local function gamePlugin(world: tecs.World, app: tecs.Application)
tecs.gfx.layers.configure(
16,
{
sort = "z",
screenSpace = true,
unlit = true,
}
)
world:addPlugin(tecs.gfx.textPlugin({renderer = app.renderer}))
world:addPlugin(tecs.ui.plugin(app, {wheelStep = 36}))
world:addSystem({
name = "game.SpawnInterface",
phase = tecs.ecs.phases.Startup,
run = function()
local font <const> = tecs.gfx.newTTF({
source = "fonts/JetBrainsMono-ExtraBold.ttf",
name = "game-ui-16",
size = 16,
raster = "alpha",
}):wait().value
spawnInterface(world, app, font)
end,
})
end
return tecs.newApplication({
window = {
title = "My game",
width = 1280,
height = 720,
},
plugin = gamePlugin,
})Plugin setup is for composition and registration. Startup is for spawning the retained entities, after all application plugins have been installed and before the first frame.
Tests, tools, and manually constructed worlds can wire the dependencies directly instead:
world:addPlugin(ui.plugin({
renderer = renderer,
input = input,
}))That manual form has no window to synchronize. Give its screen roots explicit logical dimensions and pixel density with ui.Root("screen", width, height, pixelDensity).
Layout recipes
Every recipe below belongs inside spawnInterface and attaches to the root created there. A layout container needs Style, RelativeTransform2D, and ChildOf. It needs no drawing component unless the container itself should be visible.
Anchor a panel to a corner
The root is a row by default. justifyContent chooses the main-axis position and alignItems chooses the cross-axis position. This puts a fixed-width panel at the top right while its height follows its children:
local sidebar <const> = world:spawn(
ui.Style({
width = 360,
flexDirection = "column",
gap = 12,
padding = 16,
margin = {left = "auto"},
}),
RelativeTransform2D(),
ChildOf(root)
)For a true overlay that takes no space from siblings, use absolute positioning:
local overlay <const> = world:spawn(
ui.Style({
position = "absolute",
width = 360,
inset = {right = 24, top = 24, bottom = 24},
flexDirection = "column",
gap = 12,
}),
RelativeTransform2D(),
ChildOf(root)
)Build a horizontal toolbar
local toolbar <const> = world:spawn(
ui.Style({
width = "100%",
height = 48,
flexDirection = "row",
alignItems = "center",
gap = 8,
padding = {left = 12, right = 12},
}),
RelativeTransform2D(),
ChildOf(root)
)
for index = 1, 4 do
world:spawn(
ui.Style({width = 96, height = 32}),
RelativeTransform2D(),
ChildOf(toolbar)
)
endDivide remaining space between children
flexGrow consumes space left on the container's main axis. This creates a fixed navigation column and a content area that fills the rest:
local body <const> = world:spawn(
ui.Style({
width = "100%", flexGrow = 1, flexDirection = "row", gap = 16
}),
RelativeTransform2D(),
ChildOf(root)
)
local navigation <const> = world:spawn(
ui.Style({width = 220, height = "100%"}),
RelativeTransform2D(),
ChildOf(body)
)
local contentArea <const> = world:spawn(
ui.Style({flexGrow = 1, height = "100%"}),
RelativeTransform2D(),
ChildOf(body)
)Wrap cards into rows
local cards <const> = world:spawn(
ui.Style({
width = "100%",
flexDirection = "row",
flexWrap = "wrap",
gap = 12,
}),
RelativeTransform2D(),
ChildOf(root)
)
for index = 1, 12 do
world:spawn(
ui.Style({width = 180, height = 96}),
RelativeTransform2D(),
ChildOf(cards)
)
end
The Flex scene makes both behaviors measurable: the first row compares 88px, flexGrow = 1, and flexGrow = 2, while the cards below wrap at the panel edge.
Overlay a badge without affecting layout
An absolute child is positioned relative to its retained parent and does not consume flex space:
world:spawn(
ui.Style({
position = "absolute",
width = 20,
height = 20,
inset = {right = -6, top = -6},
}),
ui.Paint(true),
RelativeTransform2D(0, 0, 5),
Tint(1.0, 0.25, 0.2, 1.0),
Material(tecs.gfx.materials.id("circle"), 0),
Renderable2D(),
ChildOf(contentArea)
)
The Overlay scene shows that absolute children anchor to their retained parent without consuming flex space. The center circle and label also demonstrate that renderer depth remains independent from layout order.
Remove a subtree from layout
Change display through getMut so the UI plugin sees the write:
local style <const> = world:getMut(sidebar, ui.Style)
style.style.display = "none"
-- Later:
local visible <const> = world:getMut(sidebar, ui.Style)
visible.style.display = "flex"display = "none" removes the entity and its descendants from Taffy layout. It does not remove ordinary rectangle, image, or text instances from their GPU producers. A component that owns a whole-screen scene should therefore pair the layout state with its normal rendering visibility state. The repository demo's scene selector removes Renderable2D from inactive drawing leaves and restores it when their scene becomes active.
Compose visuals from ordinary entities
A container normally has Style, RelativeTransform2D, and ChildOf. Put its visual on an absolute child. Paint(true) centers that child and copies the computed width and height into its relative transform scale.
The following entity snippets belong in spawnInterface, after the root is created.
local panel <const> = world:spawn(
ui.Style({
width = 360,
height = 520,
flexDirection = "column",
padding = 20,
gap = 12,
}),
tecs.ecs.RelativeTransform2D(),
tecs.ecs.ChildOf(root)
)
world:spawn(
ui.Style({position = "absolute", inset = 0}),
ui.Paint(true),
tecs.ecs.RelativeTransform2D(0, 0, 0),
tecs.gfx.Tint(0.035, 0.055, 0.09, 0.96),
tecs.gfx.Material(tecs.gfx.materials.id("rounded"), 0.04),
tecs.gfx.Renderable2D(),
tecs.ecs.ChildOf(panel)
)The same pattern works with every drawing producer. A circle keeps the circle material, text keeps the glyph instance producer, and an image keeps its sprite. Add Intrinsic when a leaf should size itself instead of stretching:
world:spawn(
ui.Style({width = 32, height = 32}),
ui.Paint(true),
tecs.ecs.RelativeTransform2D(0, 0, 2),
tecs.gfx.Tint(0.32, 0.82, 1.0, 1.0),
tecs.gfx.Material(tecs.gfx.materials.id("circle"), 0),
tecs.gfx.Renderable2D(),
tecs.ecs.ChildOf(panel)
)
world:spawn(
ui.Style({maxWidth = "100%"}),
ui.Intrinsic("text", {wrap = true}),
tecs.ecs.RelativeTransform2D(0, 0, 2),
tecs.gfx.Tint(0.94, 0.97, 1.0, 1.0),
tecs.gfx.Text.new({
text = "Composed, not replaced",
font = font,
size = 22,
}),
tecs.ecs.ChildOf(panel)
)Draw a stretched rectangle
There is no UI rectangle type. This is the normal renderer quad, stretched to the box computed for its parent:
local card <const> = world:spawn(
ui.Style({width = 280, height = 120}),
RelativeTransform2D(),
ChildOf(root)
)
world:spawn(
ui.Style({position = "absolute", inset = 0}),
ui.Paint(true),
RelativeTransform2D(0, 0, 0),
Tint(0.10, 0.17, 0.27, 1.0),
Material(tecs.gfx.materials.id("rounded"), 0.08),
Renderable2D(),
ChildOf(card)
)Draw a circle at a fixed size
world:spawn(
ui.Style({width = 40, height = 40}),
ui.Paint(true),
RelativeTransform2D(0, 0, 1),
Tint(0.32, 0.82, 1.0, 1.0),
Material(tecs.gfx.materials.id("circle"), 0),
Renderable2D(),
ChildOf(card)
)Render crisp fixed-size UI text
Load an alpha font at the exact size used by Text. The same text component, atlas, producer, clipping, and instancing are used as SDF text. Exact-size alpha text on a screen-space layer also snaps its origin to a pixel:
local uiFont <const> = tecs.gfx.newTTF({
source = "fonts/JetBrainsMono-ExtraBold.ttf",
name = "settings-ui-16",
size = 16,
raster = "alpha",
}):wait().value
world:spawn(
ui.Style({maxWidth = "100%"}),
ui.Intrinsic("text"),
RelativeTransform2D(0, 0, 2),
Tint(0.94, 0.97, 1.0, 1.0),
tecs.gfx.Text.new({
text = "Video settings",
font = uiFont,
size = 16,
}),
ChildOf(card)
)Use the default raster = "sdf" when the same font must animate through many scales or live in the world. Use separate name values when loading the same source in multiple sizes or raster modes because snapshots resolve fonts by name.
Wrap text to the available width
world:spawn(
ui.Style({width = "100%", maxWidth = 320}),
ui.Intrinsic("text", {wrap = true}),
RelativeTransform2D(0, 0, 2),
Tint(0.72, 0.80, 0.90, 1.0),
tecs.gfx.Text.new({
text = "This paragraph wraps when its parent becomes narrower.",
font = uiFont,
size = 16,
}),
ChildOf(card)
)Load an image and preserve its aspect ratio
Images remain asynchronously loaded sprites. This example fixes the height at 64 logical pixels and lets Intrinsic("image") derive the width from the registered sprite region:
tecs.assets.loadImage(
tecs.io.files.assetPath("images/portrait.png")
):map(function(image: tecs.assets.Image): tecs.gfx.Sprite
return app.renderer.sprites:registerImage(image)
end):onSettle(function(loaded: tecs.Future<tecs.gfx.Sprite>)
if loaded.status == "ready" then
world:spawn(
ui.Style({height = 64}),
ui.Intrinsic("image"),
ui.Paint(true),
RelativeTransform2D(0, 0, 2),
loaded.value,
Tint(1, 1, 1, 1),
Renderable2D(),
ChildOf(card)
)
end
end)The callback captures world, app, and card from the application plugin and startup function shown earlier. It spawns only after the image has entered the renderer, so no placeholder sprite or per-frame polling is required.
Give another leaf a natural size
Use custom intrinsic metrics when a dedicated producer already knows its preferred size. Paint(true) below makes the ordinary rectangle consume those metrics:
world:spawn(
ui.Style({maxWidth = "100%"}),
ui.Intrinsic(
"custom",
{
width = 96,
height = 28,
minWidth = 48,
}
),
ui.Paint(true),
RelativeTransform2D(0, 0, 2),
Tint(0.22, 0.56, 0.68, 1.0),
Material(tecs.gfx.materials.id("rounded"), 0.12),
Renderable2D(),
ChildOf(card)
)ui.Intrinsic("image") reads the selected sprite region's source dimensions from the renderer and preserves its aspect ratio when only one axis is constrained. ui.Intrinsic("custom", {width = 80, height = 24}) supplies cached metrics for another leaf producer. scale converts source units into UI units.
Text wrapping is a retained convergence step. Taffy chooses the available width, the UI plugin asks SDL_ttf for the exact wrapped height, and only that dirty root is solved again. No native-to-Lua callback runs inside Taffy and unchanged text is not reshaped each frame. The chosen width is retained in Text.wrapWidth, so the ordinary text instance producer draws the same lines that layout measured.
Do not stretch a container that owns layout children. Its transform scale would compose into every descendant. Stretch a dedicated absolute drawing child instead.
Style values and mutation
Numbers and px strings are logical UI pixels; pixel density maps them to the render target. Percent strings are parent-relative, and auto selects automatic size:
ui.Style({
width = "100%",
minHeight = "120px",
flexBasis = "auto",
flexGrow = 1,
margin = {left = "8px", right = "8px"},
})Plain numbers remain the compact form of logical pixels, so padding = 24 and padding = "24px" are equivalent. The older {value = 100, unit = "percent"} representation remains supported.
Strings are converted to retained typed dimensions only when a style is added or marked dirty. Window resizing changes the root's available space without reparsing unchanged styles. Unchanged frames also skip layout export, clipping, and hit-rectangle reconstruction.
Supported properties are display, position, flexDirection, flexWrap, justifyContent, alignItems, alignContent, flexGrow, flexShrink, flexBasis, width, height, minimum and maximum dimensions, margin, padding, border, gap, rowGap, and inset. See the Style.style contract for accepted values.
Styles are retained component data. Mutate through getMut so the plugin can synchronize the changed node into the retained layout tree:
local style <const> = world:getMut(panel, ui.Style)
style.style.width = 440Switch a responsive layout at a breakpoint
Screen roots resize automatically, but a game may still want a deliberate layout change at a logical-width breakpoint. Cache the selected direction so getMut is called only when the breakpoint changes:
System recipes belong in gamePlugin, beside the Startup system, rather than inside spawnInterface. Keep the entity ids in plugin-local variables, assign them from Startup, and guard the first update until spawning has finished. This abbreviated recipe assumes root and body are those retained ids:
local narrow = false
world:addSystem({
name = "game.ResponsiveUi",
phase = tecs.ecs.phases.Update,
run = function()
if root == 0 or body == 0 then
return
end
local rootValue <const> = world:get(root, ui.Root)
local nextNarrow <const> = rootValue.width < 720
if nextNarrow ~= narrow then
narrow = nextNarrow
local style <const> = world:getMut(body, ui.Style)
style.style.flexDirection = narrow and "column" or "row"
end
end,
})Read a computed box
Layout is engine-owned output. Read it after UI layout when another system needs the final logical dimensions:
world:addSystem({
name = "game.ReadPanelLayout",
phase = tecs.ecs.phases.Last,
run = function()
local box <const> = world:get(panel, ui.Layout)
if box ~= nil then
print(
("panel is %.0f by %.0f"):format(box.width, box.height)
)
end
end,
})Do not write Layout, Transform2D, or RelativeTransform2D to resize a retained node. Change its Style; the plugin owns the computed outputs.
Scroll and clip descendants
Add Scroll to the viewport and make the larger content entity its child. Wheel input targets the deepest viewport under the pointer. The plugin clamps the offset, moves descendants after layout, intersects nested viewports, and writes ordinary tecs.gfx.Clip indices to drawing entities.

The Scroll scene labels the edge cases directly: fixed viewport size, clipped descendants, nested wheel handoff, focus reveal, and a scrollbar assembled from ordinary rectangle entities.
local viewport <const> = world:spawn(
ui.Style({
width = "100%",
height = 240,
}),
ui.Scroll(),
tecs.ecs.RelativeTransform2D(),
tecs.ecs.ChildOf(panel)
)
local content <const> = world:spawn(
ui.Style({
width = "100%",
height = 600,
flexDirection = "column",
gap = 8,
}),
tecs.ecs.RelativeTransform2D(),
tecs.ecs.ChildOf(viewport)
)Populate the content with ordinary retained rows. The larger authored content height is what creates overflow:
local function spawnRow(label: string, tabIndex: integer)
local row <const> = world:spawn(
ui.Style({width = "100%", height = 44}),
ui.Interaction({tabIndex = tabIndex}),
RelativeTransform2D(),
ChildOf(content)
)
world:spawn(
ui.Style({position = "absolute", inset = 0}),
ui.Paint(true),
RelativeTransform2D(0, 0, 1),
Tint(0.12, 0.20, 0.30, 1.0),
Material(tecs.gfx.materials.id("rounded"), 0.08),
Renderable2D(),
ChildOf(row)
)
world:spawn(
ui.Style({
position = "absolute", inset = {left = 12, top = 14}
}),
ui.Intrinsic("text"),
RelativeTransform2D(0, 0, 2),
Tint(0.9, 0.95, 1.0, 1.0),
tecs.gfx.Text.new({text = label, font = font, size = 16}),
ChildOf(row)
)
return row
end
local lastRow = 0
for index = 1, 20 do
lastRow = spawnRow(("Item %02d"):format(index), index)
endProgrammatic scrolling writes Scroll.x or Scroll.y through getMut. Offsets clamp to the complete retained extent, including negative and absolute descendants. contentX, contentY, contentWidth, and contentHeight are engine-owned measurements. Snapshots retain x and y; the derived content fields are rebuilt after load.
-- Scroll by a logical amount. The plugin clamps it during layout.
local scroll <const> = world:getMut(viewport, ui.Scroll)
scroll.y = scroll.y + 80
-- Or expose a particular descendant through every nested viewport.
ui.reveal(world, lastRow, "center")Wheel movement starts at the deepest viewport and hands any unused distance to its ancestors. It follows the platform's configured scroll direction while leaving Input.wheelX and Input.wheelY available to directional gameplay bindings with their stable sign convention. Shift converts a vertical wheel into horizontal movement when the device reports no horizontal axis. Call ui.reveal(world, entity) to expose a descendant through every ancestor viewport. Focusing an entity does this automatically.
Taffy does not create or draw a scrollbar. It lays out the viewport and content. The UI system turns those retained measurements and Scroll.y into the thumb's RelativeTransform2D; the thumb itself uses the same ordinary material and instance components used elsewhere:
world:spawn(
ui.Scrollbar("vertical", 6, 2, 18),
ui.Interaction({focusable = false, draggable = true, order = 100}),
tecs.ecs.RelativeTransform2D(0, 0, 10),
tecs.gfx.Tint(0.4, 0.7, 0.9, 0.9),
tecs.gfx.Material(tecs.gfx.materials.id("rounded"), 0.5),
tecs.gfx.Renderable2D(),
tecs.ecs.ChildOf(viewport)
)The thumb receives a zero length when its axis has no overflow. Horizontal scrollbars use the same component with "horizontal". A rail, border, end blocks, arrow controls, or grip marks are optional composed entities. The repository demo uses squared rectangles for a chunky retro skin; none of that appearance lives in Taffy or in the scrollbar component.
Here is a complete squared, blocky scrollbar skin. Only the middle entity carries Scrollbar; the track and end blocks are ordinary decoration:
world:spawn(
ui.Style({
position = "absolute",
width = 14,
inset = {right = 0, top = 0, bottom = 0},
}),
ui.Paint(true),
RelativeTransform2D(0, 0, 8),
Tint(0.05, 0.09, 0.14, 1.0),
Material(tecs.gfx.materials.id("rounded"), 0),
Renderable2D(),
ChildOf(viewport)
)
world:spawn(
ui.Scrollbar("vertical", 12, 16, 28),
ui.Interaction({focusable = false, draggable = true, order = 100}),
RelativeTransform2D(0, 0, 10),
Tint(0.32, 0.82, 1.0, 1.0),
Material(tecs.gfx.materials.id("rounded"), 0),
Renderable2D(),
ChildOf(viewport)
)
for _, top in ipairs({true, false}) do
world:spawn(
ui.Style({
position = "absolute",
width = 14,
height = 14,
inset = top and {right = 0, top = 0}
or {right = 0, bottom = 0},
}),
ui.Paint(true),
RelativeTransform2D(0, 0, 11),
Tint(0.22, 0.56, 0.68, 1.0),
Material(tecs.gfx.materials.id("rounded"), 0),
Renderable2D(),
ChildOf(viewport)
)
endObserve clicks and keyboard activation
Interaction adds clip-aware hit testing and transient InteractionState. Every mouse or touch identity captures independently. Releasing over the same target emits click followed by activate. A draggable interaction emits dragStart, dragMove, and dragEnd; losing its input layer emits dragCancel. Tab and Shift-Tab move focus and repeat after a short hold; Return and Space activate the focused control.
local type uiTypes = require("tecs.ui")
local type UiEvent = uiTypes.Event
local button <const> = world:spawn(
ui.Style({width = 180, height = 44}),
ui.Interaction({tabIndex = 1}),
tecs.ecs.RelativeTransform2D(),
tecs.ecs.ChildOf(content)
)
world:observe(
button,
ui.Event,
function(event: UiEvent)
if event.kind == "activate" then
print("activated via", event.source)
end
end
)Interaction makes the container selectable; it does not draw a button. Add ordinary visual and text children to complete it:
local buttonVisual <const> = world:spawn(
ui.Style({position = "absolute", inset = 0}),
ui.Paint(true),
RelativeTransform2D(0, 0, 1),
Tint(0.12, 0.20, 0.30, 1.0),
Material(tecs.gfx.materials.id("rounded"), 0.16),
Renderable2D(),
ChildOf(button)
)
world:spawn(
ui.Style({position = "absolute", inset = {left = 14, top = 13}}),
ui.Intrinsic("text"),
RelativeTransform2D(0, 0, 2),
Tint(0.9, 0.95, 1.0, 1.0),
tecs.gfx.Text.new({text = "Apply", font = font, size = 16}),
ChildOf(button)
)Events bubble through ChildOf. An observer can set event.consumed = true to stop ancestor delivery. Consuming a wheel event also suppresses default scrolling.
world:observe(
panel,
ui.Event,
function(event: UiEvent)
if event.kind == "activate" then
print("panel saw activation from", event.target)
event.consumed = true
elseif event.kind == "wheel" and menuIsLocked then
event.consumed = true
end
end
)event.pointerId distinguishes simultaneous touches and event.pointerType reports "mouse" or "touch". Pointer movement and drag events put their movement in deltaX and deltaY.
Update hover, press, focus, and drag colors
Keep the control and its drawing child as separate entities. The update system reads transient state from the control and dirties the GPU tint only when the selected color changes:
world:addSystem({
name = "game.ButtonVisualState",
phase = tecs.ecs.phases.Update,
run = function()
local state <const> = world:get(button, ui.InteractionState)
local tint <const> = world:get(buttonVisual, Tint)
local r, g, b = 0.12, 0.20, 0.30
if state.dragging or state.pressed then
r, g, b = 0.14, 0.47, 0.62
elseif state.hovered then
r, g, b = 0.17, 0.34, 0.48
elseif state.focused then
r, g, b = 0.18, 0.29, 0.46
end
if tint.r ~= r or tint.g ~= g or tint.b ~= b then
local changed <const> = world:getMut(buttonVisual, Tint)
changed.r, changed.g, changed.b = r, g, b
end
end,
})Register update systems from gamePlugin, beside the Startup system. Keep the spawned entity ids in plugin-local variables so those systems can read them after startup.
Handle dragging and simultaneous pointers
local slider <const> = world:spawn(
ui.Style({width = 240, height = 32}),
ui.Interaction({tabIndex = 2, draggable = true}),
RelativeTransform2D(),
ChildOf(panel)
)
local dragByPointer: {string: number} = {}
world:observe(
slider,
ui.Event,
function(event: UiEvent)
if event.kind == "dragStart" then
dragByPointer[event.pointerId] = 0
elseif event.kind == "dragMove" then
dragByPointer[event.pointerId] = (
dragByPointer[event.pointerId] or 0
) + event.deltaX
elseif
event.kind == "dragEnd" or event.kind == "dragCancel"
then
dragByPointer[event.pointerId] = nil
end
end
)Each touch and the mouse has its own pointerId, capture, and drag lifecycle. Do not use one global dragging boolean when a control needs to distinguish simultaneous touches.
Call ui.focus, ui.blur, and ui.focused for programmatic focus. A modal panel can carry ui.FocusScope() and constrain navigation until popped:
ui.focus(world, firstField)
ui.pushFocusScope(world, dialog)
-- The active scope now owns Tab traversal and pointer targeting.
ui.popFocusScope(
world, dialog
) -- Restores the prior focus when possible.This is a complete focus-scope container. Controls spawned below dialog become the only Tab and pointer targets while the scope is active:
local dialog <const> = world:spawn(
ui.Style({
position = "absolute",
width = 440,
height = 260,
inset = {left = "25%", top = "20%"},
flexDirection = "column",
padding = 20,
gap = 12,
}),
ui.FocusScope(),
RelativeTransform2D(0, 0, 20),
ChildOf(root)
)
local cancelButton <const> = world:spawn(
ui.Style({width = 120, height = 44}),
ui.Interaction({tabIndex = 1}),
RelativeTransform2D(),
ChildOf(dialog)
)
ui.pushFocusScope(world, dialog)
ui.focus(world, cancelButton)
world:observe(
cancelButton,
ui.Event,
function(event: UiEvent)
if event.kind == "activate" then
ui.popFocusScope(world, dialog)
world:despawn(dialog)
end
end
)Choose deterministic overlap and navigation order
local back <const> = world:spawn(
ui.Style({
position = "absolute", width = 160, height = 48, order = 1
}),
ui.Interaction({tabIndex = 1, order = 1}),
RelativeTransform2D(0, 0, 4),
ChildOf(panel)
)
local front <const> = world:spawn(
ui.Style({
position = "absolute", width = 160, height = 48, order = 2
}),
ui.Interaction({tabIndex = 2, order = 2}),
RelativeTransform2D(0, 0, 5),
ChildOf(panel)
)Style.style.order places siblings in layout. Interaction.order breaks hit and focus ties. Transform2D Z determines renderer depth. Set all three when two controls intentionally overlap rather than relying on entity creation order.
Interaction.tabIndex defines the primary navigation order. Interaction.order is the explicit authorial tie-breaker for focus and hit testing, while Style.style.order controls retained sibling layout order. Equal values fall back to the order in which entities first entered the retained tree, not to entity identity. Give overlapping controls explicit orders when their stacking is meaningful.
Read InteractionState from an update system to select hover, pressed, and focused colors. Only call getMut on the visual component when the selected color actually changes, preserving dirty-gated GPU synchronization.
Editing and accessibility boundary
The current interaction layer stops at semantic activation and dragging. Text editing, selection, IME composition, clipboard commands, controller spatial navigation, and platform accessibility exposure are designed but not yet implemented. In particular, a Text drawing entity is not implicitly an edit control, and an Interaction does not invent a role or accessible name.
Those features will remain composed ECS state: editing state beside a Text leaf, platform text input entered only while that entity is focused, controller actions routed through the input layer, and a semantic snapshot bridged from Rust without an FFI callback into Lua. They do not require a DOM, CSS cascade, or parallel widget renderer.
Run the examples
The repository demo shows layout, rectangle and circle materials, text, an asynchronously loaded image, nested clipping, wheel scrolling, pointer capture, bubbling events, and keyboard navigation:
cargo xtask example ui-demo
main.tl.