Skip to content

Latest commit

 

History

History
948 lines (794 loc) · 54.1 KB

File metadata and controls

948 lines (794 loc) · 54.1 KB

Writing Lua apps — a guide

How to write an app for the osvauld shell. For humans and agents alike; the reference app is kanban (demo_apps/kanban/ — the files that use everything in this guide), and demo_apps/tally is the smallest complete app.

Shape

An app is a folder of .lua files with a root main.lua that returns the view function. require("theme") loads theme.lua from your own folder (ui/widgets.lua ↔ require("ui/widgets")); required modules run once per app open and are cached — so anything that must happen once (seeding a document) goes at module scope, guarded, because module scope runs again on every open. manifest.osv is optional and only supplies the display name.

local C = require("theme")
local t = doc:open("tally")                 -- this app's document, by name

if not t.count then                         -- guarded seed
    t:set({ "count" }, doc.map({ n = 0 }))
end

return function()
    local n = t.count and t.count.n or 0
    return ui.col({
        id = "tally",
        grow = true,
        center = true,
        fill = C.bg,
        ui.text({ tostring(n), color = C.text, font_size = 72, no_wrap = true }),
        ui.button({
            id = "inc", h = 34, radius = 8, center = true, fill = C.accent,
            ui.text({ "+1", color = "#ffffff", font_size = 13 }),
            on_click = function() t:set({ "count", "n" }, n + 1) end,
        }),
    })
end

The model — Elm, in Lua

The architecture is Elm's: state, view, update.

  • view is your returned function. It takes nothing and returns a description of the screen built from state. It is pure: no side effects, no input handling — just a description.
  • update is your handlers (on_click and friends). They run after the frame, as messages; they change state, and the next view reflects it. Kanban routes every handler through one update(msg) that dispatches on msg.kind to an actions table — that discipline is recommended, not required.
  • state lives in three places, each with a job — see State.

Two consequences to internalize:

  • The description is rebuilt constantly — every message, every pointer move. Keep view cheap: derive, don't compute the world. Nothing is mutated in place; you never "update the UI", you change state and describe it again.
  • ids are the continuity mechanism. The shell keeps per-element state (scroll offsets, animation progress, input carets) keyed by id. An element without one is anonymous — which is fine for static things, and exactly why scroll_*, on_drag, on_drop, fade and every ui.input need one.

Elements and children

Three kinds of table exist in an app, and only the first draws:

kind example reaches the screen
element ui.col({ … }) yes
splice group local body = {} … ui.col({ …, body }) its contents, spliced in
data doc.map({ … }), stroke = { 1, C.line } no

Only the ui.* constructors make elements — never hand-write tag = "col". A bare table nested as a child splices its contents into the parent (kanban builds body imperatively and nests it whole).

Children are positional entries and their order is meaning — it is the child list. A bare string child becomes a text element; false drops out (ghost or false is the idiom for conditional children).

Frame visuals (experimental foundation)

Anything you can describe with coordinates, you can draw. Paths and brushes are compiled once into immutable resources, assembled into a visual, and published as a normal leaf with ui.frame({ visual = v, …normal props… }). Frame dimensions are intrinsic layout claims, not an implicit clip or scale.

call fields notes
gfx.path(commands) positional list of commands up to 65536; drawing before move is an error
gfx.solid(color) a CSS color string
gfx.linear_gradient({…}) from = {x, y} · to = {x, y} · stops = {{offset, color}, …} · extend 2–64 stops, offsets 0–1; extend is pad (default), repeat, reflect
gfx.frame({…items}) width · height · (baseline) + items as positional children width/height required; the result reads them back (f.width, f.height)
gfx.fill({…}) path · brush · (rule) rule is nonzero (default) or evenodd
gfx.stroke({…}) path · brush · width · (cap · join · miter_limit · dashes · dash_offset) cap: butt (default) · square · round. join: miter (default) · bevel · round. miter_limit 4, dashes {} (max 64), dash_offset 0
gfx.group({…items}) transform = {xx, yx, xy, yy, dx, dy} + items
gfx.instance({…}) visual (another frame) · transform placed by its local origin — account for a centered shape's radius

fill, stroke, group and instance take an optional id — and that is what makes the drawing touchable. Put the pointer handler on the ui.frame itself, and on_click/on_hover report which named shape the pointer is on and where on it:

ui.frame({
    id = "pie",
    visual = v,
    on_hover = function(e) hot = e.phase ~= "leave" and e.shape or nil end,
})

Unnamed shapes are paint: the pointer falls through them to whatever is underneath. Where two named shapes overlap the later one wins, because that is the one you see. A named container answers as one shape and the names inside it stop being reachable — that is how you choose the granularity, a whole dial or each of its ticks — and the names inside an instanced visual are never reachable, since every instance would answer to the same ones. An id'd item with no brush is an invisible hit region. A stroke is hit within half its width of the line; caps, joins and dashes aren't modelled, so a dashed line is one line to the pointer.

Path commands are positional with exact arity: {"move", x, y}, {"line", x, y}, {"quad", cx, cy, x, y}, {"cubic", c1x, c1y, c2x, c2y, x, y}, {"close"}. Everywhere else in gfx, fields are named — a stray positional entry in a fill or a named key in a path is an error, not an ignored extra.

There is no text inside a frame, no arcs, and no internal clips yet; labels are ui.text siblings positioned by layout, which is what the demo charts' axes do.

Drawings (experimental)

A drawing is a character or prop as pure data — a module of tables, numbers and strings, no functions and no gfx handles — so an agent can edit it and a future editor can rewrite it. gfx.drawing(module) validates it; drawing:pose(overrides) returns an ordinary gfx.frame.

-- hero.lua
return {
	size = { 160, 240 },
	parts = {                                            -- list order is draw order
		{ id = "arm_far", parent = "body", pivot = { 56, 98 }, shapes = { … } },
		{ id = "body", pivot = { 80, 166 }, shapes = {
			{ path = { {"move",66,88}, {"line",94,88}, … , {"close"} },
			  fill = "#f84aa7", stroke = { 2, "#1b1b3a" } },
		} },
		{ id = "hip", parent = "body", pivot = { 80, 166 } }, -- no shapes: a group
	},
}

-- main.lua
local hero = gfx.drawing(require("hero"))
local waving = hero:pose({ arm_near = { rot = -150 }, head = { rot = 8 } })
ui.frame({ id = "hero", visual = waving })
  • Parts are what move; shapes are the painting inside a part — fill under stroke, sharing one path. A shape needs fill, stroke or both; strokes join and cap round.
  • parent is the hierarchy and list order is draw order, independently: a far arm belongs to the body and still draws behind it. A parent may be listed after its child.
  • Coordinates, paths and pivot (required) are all in the drawing's own space.
  • A pose override is { x, y, rot, scale } — rot in degrees — about the part's pivot; children follow. Naming a part that doesn't exist is an error.
  • Each posed part is a named group, so on_hover/on_click on the ui.frame report the part id as e.shape.
  • pose builds a new frame: pose once at module scope, not in view. Clips, use and paint beyond solid colours are not built yet — clips = … and use = … are errors.

demo_apps/hero is the reference.

Worlds (experimental)

ui.world is an element that keeps state between frames: a retained world of entities, kept by id. You describe the entities; the world spawns, keeps and despawns them to match.

local hero = gfx.drawing(require("hero"))

ui.world({
	id = "room", width = 720, height = 400, fill = "#2a3b33",
	on_hover = function(e) hot = e.shape end,         -- e.shape is the entity id
	{ id = "hero", pos = { 120, 80 }, drawing = hero },
	friend and { id = "friend", pos = { 320, 100 }, drawing = hero } or false,
})
  • Entities are the positional children, as data: id, pos, drawing (a gfx.drawing handle) and optionally clip (a gfx.clip handle), controller, flip, attach, collider, sensor, loose, group and blocks — nothing else. false drops out, like a child element. List order is draw order, unless the world has order = "y": then whoever's feet (the bottom of the drawing's box) stand lower draws in front, ties keeping list order — a top-down room.
  • pos is where an entity spawns, and only that. Once it exists the world owns where it is; re-sending a different pos does not move it.
  • An id the description no longer lists is despawned. A new drawing handle replaces the look; make drawings once at module scope so the handle is stable.
  • id, width and height are required; ordinary box and paint props (fill, radius, stroke) and pointer handlers apply to the element. The world clips to its box: a head or hand spilling past the edge is neither drawn nor hit there.
  • The world outlives hot reload and survives a hidden tab. It is dropped when a successful frame no longer draws its id. A bad description is an error and keeps the last good world.
  • flip = true mirrors the drawing within its box — a side view drawn facing right, shown facing left.
  • attach = { to = "hero", part = "body", at = { 32, 120 } } carries the entity: its box's top-left rides at at — a point in the carrier's drawing at rest — as that part moves and animates (a flipped carrier mirrors the box too). It draws just in front of its carrier. Remove attach and it stays where it was last carried. The carrier must be described, not itself attached, and have the part.
    • pivot = { x, y } (default {0, 0}, the box's top-left) is the point of the carried entity's own drawing placed on at — a hat's plug, a sword's grip. Under a flipped carrier, give the carried entity flip = true too; its mirrored pivot still lands on the point.
    • turn = true makes it take on the part's rotation, scale and mirror, about its pivot: a hat nods with the head, a sword swings with the hand. Without it the entity stays upright (a chest in the arms). A turned entity mirrors with its carrier, so flip with turn is an error. Turning is drawing only: a carried entity has no body, and let go it lands upright.
  • collider = { circle = 16, at = { 80, 222 } } or { rect = { 720, 12 } } makes the entity solid, in its drawing's units: at (default {0, 0}) is a circle's centre or a rect's top-left corner. An entity with a controller stops at solid entities and slides along them; one without never moves, so it is a wall. Give a character a small circle at its feet, not its whole drawing: top-down, the body stands up out of the floor, and only the feet meet a wall. Nothing checks where an entity spawns — a pos inside a wall is the author's mistake.
  • The bridge's dump_tree shows a world's entities on its element, under world.entities: each one's id, pos (box top-left), body (fixed, moved, thrown, loose or none), velocity per second, attached ({ to, part }), the zones it is in, and its clip (time, length, looped). Check a world by reading it, not by probing pixels.
  • A carried entity is off the floor: its collider and sensor go while attach is set. Let go, it keeps its carrier's momentum, slides, bounces off walls and settles where it stops — never inside a wall: carried, nothing stopped its footprint going into one, so let go it comes from its carrier's body to its spot and stops against whatever is in the way. Top-down, height is a pose, not a place: attach the thing where it would stand on the floor and let a clip lift its drawing into the hands; let go, a fall clip drops the drawing back to a footprint that never left the floor (the demo's lift / fall).
  • sensor = { circle = 72, at = { 48, 68 } } is a zone in the same shape words: it blocks nothing, and on_zone reports what comes into it and leaves it. An entity may have a collider, a sensor, both, or neither.
  • loose = true (or loose = { bounce = 0.9, friction = 0.5 }) hands an entity to physics: a walker pushes it, it bounces off what it hits and the floor slows it. bounce is the share of speed kept off a wall, 0 to 1 (default 0.5); friction is speed lost per second, 0 or more (default 6, a heavy crate; a ball wants under 1). It needs a collider, and cannot have a controller. At rest it sleeps, still loose, and the next push wakes it.
  • group = "paddle" puts an entity's collider in a named group; blocks = { "paddle" } makes a collider stop only those groups. Without either, a collider is in the common group and stops everything. A hockey centre line is blocks = { "paddle" }: the paddles stop at it, the puck crosses. Both need a collider; a world has at most 31 group names.

Clips

A clip is animation as data, played by the world in Rust — no Lua runs per frame.

local open = gfx.clip({
	length = 0.5,                -- seconds; required
	loop = false,                -- default: play once and hold the last pose
	tracks = { lid = { rot = { {0, 0}, {0.5, -100, "in_out"} } } },
})
ui.world({ id = "room", width = 720, height = 400,
	{ id = "chest", pos = { 520, 240 }, drawing = chest, clip = lid_open and open or nil },
})
  • tracks maps a part id to properties x, y, rot (degrees) and scale, each a list of keys {time, value, easing?} — offsets from the rest pose about the part's pivot, like pose. Key times rise strictly within 0..length.
  • Easing is "linear" (the default) or "in_out", and shapes the segment arriving at that key. Before its first key a track holds the first value; after its last, the last. A looped clip's last key should match its first.
  • A clip plays from the moment its handle first appears on the entity, on the runtime's frame clock (virtual offscreen, so rpc.advance lands on exact times). Re-sending the same handle keeps it playing; a different handle restarts; no clip returns to rest. Make clips once at module scope, and switch clips by switching handles.
  • A clip track naming a part the entity's drawing lacks is skipped, not an error — a drawing edited live must not stop the world. The console says so once per entity and part (again only if the part comes back and goes missing anew); the clip's other tracks play. An attach to a missing part stays an error: there is nowhere to put the carried thing. Unknown fields are errors — events, speed, transitions and layers are not built yet.
  • A world with a clip playing drives its own frame ticks, so on_frame on a ui.world is an error; put it on an element around the world.

Controllers

A controller moves its entity from held keys, in Rust — Lua describes it once and runs nothing per key or per frame.

local wasd = {
	speed = 160,                               -- units a second; required
	axis_x = { neg = "KeyA", pos = "KeyD" },   -- at least one axis
	axis_y = { neg = "KeyW", pos = "KeyS" },
}
{ id = "hero", pos = { 120, 80 }, drawing = hero, clip = idle, controller = wasd }
  • Key names are physical codes, the same as on_key's e.code. A misspelt code ("W" for "KeyW") is not caught — it simply never matches.
  • Both keys of an axis held cancel; a diagonal is no faster than a straight line. Movement uses the runtime's frame dt, capped at 0.1s after a stall, so rpc.frame(n) is the exact way to drive it offscreen — rpc.advance moves by at most one capped step.
  • A world with a controller or actions takes keyboard input itself (it attaches on_key, and releases every key on blur), so on_key on a ui.world is an error. A world with neither leaves keys alone.
  • There are no walls yet: nothing stops an entity leaving the world's box.

Moments — on_action, on_move, on_clip_end, on_zone, on_timer and on_hit

The world does the per-frame work; the moments come to Lua to decide on. A handler runs a few times a second at most, never once a frame.

actions = { interact = "KeyE", jump = "Space" },
on_action = function(e) if e.action == "interact" then toggle_lid() end end,
on_move = function(e) print(e.id, e.dx, e.dy) end,   -- -1/0/1 each; 0, 0 is stopped
  • actions maps an action name to a key code. on_action(e) gets e.action on a fresh press — a held key's repeats are not presses. actions and on_action come together or not at all.
  • on_move(e) gets e.id, e.dx, e.dy when a controlled entity's held direction changes: it starts, turns or stops. Standing still at spawn is not a change.
  • Actions are the world's, not an entity's: the press is the player's, and Lua decides which entity it concerns.
  • on_clip_end(e) gets e.id when a once clip on that entity reaches its end — once per play; re-sending the same handle is the same play. A looped clip never ends.
  • on_zone(e) gets e.id (the entity whose sensor it is), e.who and e.phase — "enter" or "leave" — when something solid or moving comes into the zone or goes out of it; walls never count. Taking a sensor away (carrying, despawning) is a leave for whoever was in it; a despawned who leaves quietly. (on_enter is the Enter key on an input, so the world's is on_zone, phased like on_hover.) The demo's reach to the chest is one flag: near = e.phase == "enter", and E picks up only when near.
  • on_hit(e) gets e.id, e.who and e.speed when a loose (or thrown) thing comes into contact with something solid: e.id is the moving one, e.who what it met, and e.speed how fast they closed along the contact, in units a second — a tap is slow, a slap fast. Once per meeting: resting against a wall afterwards is not more hits, and under 1 unit a second is settling, not a hit. Two loose things meeting each get one. A walker meeting a wall is not a hit — a walker stops itself — only what Rapier moves hits.
  • A jump is all three together, with no jump in Rust: on_action sets jumping, the hero describes clip = jumping and jump or … (a once clip lifting body, which the other parts hang off — the feet stay put, so draw order ignores it), and on_clip_end clears jumping.

Commands — world(id):set

A description never moves an entity that already exists: its pos is where it spawns. To change one at a moment — a puck back on the spot, a striker launched — a handler commands the world:

world("rink"):set("puck", { pos = { 434, 234 } })             -- put it there
world("rink"):set("puck", { velocity = { 600, 0 } })          -- send it off; { 0, 0 } stops it
  • pos is the drawing box's top-left, as in the description; velocity is per second, and only a loose thing has one to set. Either may be left out; any other field is an error.
  • It applies at once — the dump shows it before the next frame. Something put inside a solid is pushed out, as when it spawns there.
  • Only in handlers: set inside view is an error, since a description only describes. A carried entity goes where its carrier puts it, so it cannot be set.
  • The names are the dump's: what set writes is what an agent reads back.

A moment later is a timer, kept by Rust on the world's frame clock — Lua runs once, when it fires:

world("rink"):after(1.5, "faceoff")      -- on_timer gets e.name == "faceoff" then
world("rink"):cancel("faceoff")          -- never mind
on_timer = function(e) if e.name == "faceoff" then put_puck_back() end end,   -- on ui.world
  • It counts from the world's next frame, since a handler has no clock of its own. The same name again starts it over — a cooldown is after on every press.
  • A pending timer keeps the world ticking; it only runs while the world is on screen. The dump lists them as timers = { { name, left } } beside entities.

Facing and gait — decided in Lua

The world reports a change of direction; Lua picks what the entity shows. A top-down character is drawn as views — front, back, side — sharing part ids so one clip plays on all of them.

local views = {
	down = { drawing = hero, walk = walk },
	up = { drawing = hero_back, walk = walk },
	right = { drawing = hero_side, walk = walk_side },
	left = { drawing = hero_side, walk = walk_side, flip = true },   -- the side view, mirrored
}
on_move = function(e)
	walking = e.dx ~= 0 or e.dy ~= 0
	face = face_for(e.dx, e.dy, face)     -- the app's own rule; stopping keeps the facing
end,
{ id = "hero", drawing = views[face].drawing, flip = views[face].flip,
	clip = walking and views[face].walk or idle, controller = wasd },
  • The clip switches on the frame after the move starts: Rust moves on the tick, Lua re-describes after on_move.
  • Hits still name the entity, whatever view is showing.

demo_apps/world is the reference: a hero who idles and walks with WASD, and a chest whose lid opens on click or on E, through on_action.

3D scenes (experimental proof)

gfx.scene3d compiles a bounded immutable scene containing a perspective camera and up to 256 built-in cubes. Display it with ui.scene3d({ scene = scene, ...normal layout props... }); the viewport size comes from ordinary layout. Camera fields are eye, target, optional up, fov_y in degrees, near and far. Object fields are a stable id, optional mesh = "cube", position, quaternion rotation = {x, y, z, w}, positive scale and CSS color.

This is a rendering proof, not the public Environment API. It currently supports one visible 3D viewport, 4× MSAA, simple directional lighting and app-driven rebuilds. The model-viewer demo uses on_drag for orbit and on_wheel for zoom. An on_click on the scene leaf ray-picks the nearest visible cube and reports its stable ID, world hit point, normal and distance. GLB assets, hover picking and correct foreground Vello overlay composition remain unbuilt. DumpTree includes the validated camera, objects, transforms and colors so an agent can inspect the same scene declaration that renders.

Layout

Flexbox: ui.col stacks children vertically, ui.row lays them out horizontally. Children that should share the leftover space use grow (a bool = equal share, a number = weighted share); children that should fill the other axis sit in a stretch parent.

Sizing — w/h fixed sizes · min_w/max_w/min_h/max_h bounds (a grow child without a floor can be squeezed to nothing) · w_full/h_full/full · no_shrink (refuse to be squeezed: labels, badges).

Alignment — center centers on both axes · align_center centers on the cross axis only (cards under a title) · stretch makes children fill the cross axis.

Spacing — gap between children · pad on all sides, px/py per axis · mt/mb margins.

Scrolling — scroll_x/scroll_y on a container makes it scrollable on that axis; children keep their natural size there. A scrolling container with grow uses the remaining main-axis space instead of letting its content enlarge its viewport. Needs an id.

Overlay positioning — absolute takes the element out of the flow; top/left/ right/bottom position it in viewport coordinates. This is how kanban draws its drag ghost and its modal scrim. A portal popover uses ui.overlay: exactly two positional children, the anchor then the root-painted panel. side is bottom (default), top, left, or right; align is start (default), center, or end; on_dismiss adds a click-away catcher and, like every handler, needs an id:

ui.overlay({
    id = "add-menu",
    side = "top",
    align = "start",
    on_dismiss = function() open = false end,
    ui.button({ "Add" }),
    ui.col({ ui.text({ "Popover" }) }),
})

The panel escapes ancestor clips and stays screen-sized. Its element anchor follows stable placement through zoom, pan, authored scale, and offset, but ignores transient press-scale so an opening popover does not wobble while its button settles. A point anchor supplied by Rust is already screen-space.

Text — ui.text({ "label", … }) needs its label as child 1. It wraps like a paragraph by default; no_wrap makes a label. Measure happens for you.

When the window resizes — nothing to handle: relayout is automatic. An app is responsive exactly to the extent its tree uses full, grow, stretch and scroll instead of fixed sizes — kanban fills the viewport, gives its board row a definite h_full, and lets only the cards region scroll, so window drags and added cards do not enlarge the board.

Buttons have no defaults — ui.button is a ui.row and nothing more; the fill, hover states, padding and centering are all yours to declare.

Inputs — ui.input and ui.text_area require value, id and on_input = function(v) (three mandatory props, no children — put the button beside it). on_enter, on_esc and autofocus are extras. The pattern is a draft in ui.state, shown in Patterns.

Colors are any CSS color string: "#0d1117", "#rrggbbaa", "rgba(13,17,23,0.72)", "hsl(212,92%,58%)", named colors.

Reference

Constructors

Nine, and no others. Anything else is unknown tag.

constructor children required notes
ui.col({…}) any — vertical flex
ui.row({…}) any — horizontal flex
ui.button({…}) any — a ui.row and nothing more — no fill, padding or centering of its own
ui.text({ "label", … }) the label is child 1 — wraps like a paragraph; no_wrap makes it a label
ui.input({…}) none value · id · on_input extras: on_enter · on_esc · autofocus
ui.text_area({…}) none value · id · on_input same, multi-line
ui.frame({ visual = v, … }) none visual (a gfx.frame) the frame's width/height are its layout claim
ui.scene3d({ scene = s, … }) none scene (a gfx.scene3d) experimental; viewport size comes from layout
ui.overlay({ anchor, panel, … }) exactly two — takes only id · side · align · on_dismiss — no box or paint props; style the panel child instead

ui.state(id, init) is not an element — it is per-viewer scratch, see State.

Props

Every prop below works on every element (ui.overlay excepted, above). Anything not in this list is an error, not a warning — there is no silent ignore, so a typo shows up as a red box in place rather than as a missing effect. Types are checked too (expected a number got string).

Shorthands are applied before the longhands that override them, whatever order the Lua table happens to be in: full before w/h, size before both, pad before px/py, fade_in before fade.

group prop value
box full · w_full · h_full bool — fill the parent on both axes / one
size {w, h}
w · h number
min_w · max_w · min_h · max_h number (a grow child with no floor can be squeezed to nothing)
grow bool, or a number for a weighted share — grow = 2 beside grow = true is 2:1
no_shrink bool — refuse to be squeezed (labels, badges)
wrap bool — flex children onto more lines
spacing pad · px · py · gap · mt · mb number
alignment center · align_center · stretch bool — both axes / cross axis only / children fill the cross axis
positioning absolute bool — out of the flow
top · left · right · bottom number, viewport coordinates
offset {dx, dy} — shifts paint, not layout
scale number — scales this subtree, layout unchanged
paint fill · color a CSS color string (color is the text one)
radius · opacity · font_size number
stroke {width, color}
stroke_dash {width, color, dash, gap}
no_wrap bool
hover / press hover_fill · press_fill color — the transition to it is automatic
hover_stroke · press_stroke {width, color}
tint milliseconds — the fade time for a hover brightening, not a color
press_scale number, e.g. 0.96 — needs id and on_click
animation fade_in ms
fade {target_opacity, ms}
slide_in {{dx, dy}, ms} — a nested pair, then the duration
pointer system_cursor bool — false hides the system pointer while over this element, for an app that draws its own (demo_apps/pointer/cursor.lua)
cursor a look name ("grab", "text", any string your cursor knows) or a gfx.frame to draw — hover handlers get the topmost declared one as e.look
viewport zoomable · zoom_x bool — Ctrl+wheel zooms children around the pointer, both axes or x only. Needs id.
scroll scroll_x · scroll_y bool. Needs id.
input value · autofocus string · bool

Four props need an id because the shell keys state by it: scroll_* (offset), zoomable / zoom_x (camera), press_scale (spring), and every handler (dispatch). Asking for one without an id is an error naming the prop.

Not exposed to Lua yet, so don't go looking: right-click, text measurement or alignment inside a gfx frame, hit-testing individual Frame shapes, and reading back layout.

Handlers

Every handler needs an id, and no two elements may share an id and a handler. A handler is found by id + name when its event is delivered, not when the view was built, so a click still reaches its element when a peer edit lands between press and release — and is dropped if the element is gone.

A handler that carries more than one value is called with one table, not positional arguments — function(e), and every value is a named field on e. The two that carry nothing or a single value keep their plain form: on_enter, on_esc, on_faded_out take nothing, and on_input = function(v) takes the new text.

This is why: positionally, a short or mis-ordered signature binds the wrong values and keeps running. Writing function(phase, x, y, dx, dy, shape, sx, sy) for on_drag puts scale into shape, so shape is the number 1, looks like a shape id, and fails every lookup in silence. A wrong key is nil, which is loud the moment you index it, and a field added later can never shift the meaning of one already there.

on_drag = function(e)
	if e.phase == "start" then grab(e.shape, e.sx, e.sy) end
end
  • on_click(e) — e.x, e.y are where the click landed, from the element's top-left corner in its own units, zoom and scroll undone: a click on a 1400×900 canvas reports canvas numbers whatever the camera is doing. Fired from the bridge, with no layout, they are 0, 0. Inside a zoomable, a press that travels past 5pt pans instead. On a ui.scene3d, a visible cube hit also supplies e.object, e.distance, e.world_x/y/z and e.normal_x/y/z; these fields are absent when the ray hits no object.

  • on_hover(e) — e.phase is "enter" / "move" / "leave", e.x, e.y as on_click (outside the element on "leave"), e.down true while the primary button is held — a press or release under a still pointer fires a "move", so a cursor the app draws can show it. e.look is the cursor declared by the topmost element under the pointer that declares one — a name or the gfx.frame it supplied — or nil; a change of look is a "move" too. Declare looks on the things being pointed at and draw the cursor once at the root: neither has to know the other. An element is hovered while the pointer is inside it, like hover_fill: a parent stays hovered over its children, and an element painted on top doesn't hide the one below — check your own geometry if that matters. It is sampled every frame as well as on every pointer move, so geometry that drifts under a still pointer reports it: an element that slides under one enters where it arrives, and "move" fires when the shape beneath the pointer changes or slides, without the pointer having moved at all. What it will not do is repeat itself — a still pointer over still geometry says nothing, so "move" always means something actually changed.

  • e.shape, e.sx, e.sy on both of those name the shape inside a ui.frame's visual that the pointer is on — see Frame visuals. e.shape is the id you gave the shape, and e.sx, e.sy are the point in that shape's own coordinates, with its group and instance transforms undone. All three are absent on an element that draws no frame, or when the pointer is on none of its named shapes.

  • on_enter, on_esc, on_faded_out — plain callbacks, no argument.

  • on_input = function(v) — an input's new text.

  • on_drag(e) — e.phase is "start" / "move" / "end". e.x, e.y are the pointer in the element's own units, as on_click reports them (at "start", where the press landed, not where the 5pt slop ended). e.dx, e.dy are movement since the press, e.scale lets a root ghost match zoomed content, and e.origin_x, e.origin_y are the dragged element's screen-space origin — only a root-level ghost placing itself in screen space needs those. e.shape, e.sx, e.sy are the shape the press grabbed: the same one for the whole gesture, whatever the pointer has since slid over, and reported even once the pointer leaves it — which is what holding something means. Needs an id.

    e.t is when the pointer event arrived: monotonic seconds since the app opened, on the same clock as on_frame's e.elapsed, so a release can be measured against the frames after it. Keep the last two (e.t, e.x) and a fling is (x - x_prev) / (t - t_prev) on "end" — no on_frame running purely to hold a stopwatch. Use e.t and never now() for this: several moves usually arrive inside one frame, so anything sampled per frame divides by zero, and now() is wall-clock and can step backwards.

    A press that travels past 5pt is a drag and fires no click. A press that travels less is a click, reported where it was released. No hand is perfectly still, so the second is ordinary.

    The coordinate space is frozen at the press, not recomputed each move: e.sx, e.sy are measured in the space the shape had when you grabbed it, even if the shape has rotated or moved since. That is what makes them useful — they are a fixed grab offset for the whole gesture, so "where should this go now" is e.x - e.sx, computed the same way on every move. Read them as live coordinates instead and you write a feedback correction that fights itself, which looks like a broken drag rather than a coordinate-space mistake.

  • on_drop(e) — e.phase is "over" (while hovering) / "release"; e.x, e.y are normalized to the drop target (0–1), so e.y < 0.5 means "above the midline". Needs an id.

  • on_wheel(e) — e.dx, e.dy are normalized logical wheel deltas (line wheels use 30 points per step). The topmost eligible handler consumes the wheel before scroll/zoom ancestors. Use it for app-owned cameras such as ui.scene3d; ordinary scrolling should continue to use scroll_*. Needs an id.

  • on_key(e) — general keyboard input on the last visible element with this handler. e.code is the physical key name ("KeyW", "ArrowLeft"), independent of keyboard layout; e.key is the layout-dependent character or named key. Either may be absent if unknown. e.down and e.repeated are bools; e.shift, e.ctrl, e.alt, e.super are modifier bools. Ignore repeat for held movement. e.cancelled == true has no key identity: clear all held keys on blur, text focus or surface change. Otherwise it is false. Text fields take priority; shell F5/F12 shortcuts do not reach the app. Typed text and IME are not game-key events. Needs an id.

  • on_frame(e) — an experimental visual/prototyping loop. e.elapsed is monotonic Runner time and e.dt is clamped to 0.1 seconds after stalls; only Runner's first frame is guaranteed zero. Presence keeps repainting, so omit it to stop that request. Custom screenshots currently dispatch it too; it is not a fixed-step world scheduler. Needs an id.

Handlers run after the frame. A gesture should accumulate in your own state during "start"/"move" and commit to the document once at the end — a drag is one write, not sixty.

State

Three homes, pick by lifetime and audience:

locals ui.state(id, init) doc:open(name)
audience this viewer this viewer everyone, forever
survives reopen no yes yes
survives hot reload no yes yes
cleaned up never when its id leaves a frame never
  • Plain locals (a module-scope table like kanban's S = { drag = nil, … }) are right for gesture and interaction state: drags in flight, modal-open flags, resize widths mid-drag. They die with the app instance — usually what you want for pointer state.
  • ui.state(id, init) is right for state keyed to an element that may come and go — "draft:" .. col_id is garbage-collected when its column disappears — and for anything that should survive a hot reload, like half-written drafts.
  • Documents are right for anything that is a claim about the work: a column's name, its width, a card's text. The next person to open this app sees the board somebody arranged.

Kanban uses all three at once — that split is the example to copy.

Documents

local board = doc:open("board") opens (or creates) a named document and returns the mirror: reads are plain table indexes — board.columns[1].name, #board.cards — free and instant. Writes are explicit:

board:set({ "cards", id, "col" }, "c-doing")           -- path segments: names or ids
board:insert({ "cards" }, doc.map({ id = uuid(), col = "c-todo", text = "…" }))
board:delete({ "cards", id })
board:move({ "cards", id }, to)                        -- `to` is the index after removal

Write values are built with doc.map{…} / doc.list{…} / doc.text("…"); lists take positional entries only — a stray named key is an error, not a silent extra field.

The rules that bite, once each:

  • The mirror is a frame behind your own write. A write lands immediately; reads see it on the next view. Read what you need first, then write — never read back what you just wrote.
  • Address by stable id, never by position. Stamp id = uuid() at birth. The mirror is positional; a concurrent insert shifts it.
  • :move removes then reinserts — dragging downward lands one slot short unless you nudge the target (see target_index in kanban's model.lua).
  • Deleting while iterating collects the doomed ids first, deletes second — the list you are walking does not shrink until the next frame.
  • An unchanged write is a no-op. Don't guard against writing a value that might already be there; the document skips it.

Search

Ship an index.lua beside main.lua and your documents become searchable. It says what a searchable record is; the shell does the rest — indexes on every save, keeps the index sealed in the vault, and catches up on open (including docs written while the app was closed). demo_apps/chat is the reference.

-- index.lua
return {
	doc = "channel:*",                       -- a doc name, or a `prefix*` family of docs
	each = { "messages" },                   -- path to the collection: one record per entry
	key = function(m) return m.id end,       -- lists only: the stable id, read from the record
	fields = function(id, m, doc)            -- doc = the whole doc, for joins
		return {
			title = m.subject,               -- optional; ranks above body
			body = m.text,
			facet = { author = m.author },   -- exact filters: `author:anu`
			time = m.sent_at,                -- optional; sortable
		}                                    -- return nil to leave the record out
	end,
	rank = "recent",                         -- "relevance" (default) or "recent"
}
-- main.lua, anywhere — even in view
local hits = search.query("author:anu deploy", { limit = 20 })
for _, h in ipairs(hits) do
	print(h.doc, h.id, h.score, h.snippet)   -- open the record by (h.doc, h.id)
end

The rules:

  • index.lua runs in its own VM. No ui, no doc, no gfx, no require; records and doc are frozen copies — a write is an error. A broken index.lua is reported in the console and the app keeps running on the last good index.
  • Strict, like the rest. An unknown key in the spec, in what fields returns, or in search.query's options is an error naming it. A list collection without key is an error: a position is not an id.
  • Only what changed is re-indexed. Each record is fingerprinted; fields runs again only for a record whose value changed. A change outside each re-runs every record of that doc (it may feed a join), and so does editing index.lua.
  • Index ids for anything that changes; resolve names when you display. Index the author's id, not their display name, and a rename re-indexes nothing.
  • Queries: words match title and body (all must match); name:value words are exact facet filters. search.query is cheap to call in view — the same query against an unchanged index is answered from memo. Hits are your own app's records only.
  • Snippets are plain text around the match, no markup — draw them however you like.

Animation

Presentation animation is declarative:

  • Hover feedback animates itself. hover_fill, hover_stroke and tint are transitions bound to hover state — declare the color, the fade is automatic. tint is the odd one: its number is the fade duration in milliseconds, not a color or an amount.
  • Press feedback is declarative for clickable elements. press_fill / press_stroke apply while the pointer is down; press_scale = 0.96 scales an on_click element subtree around its centre. press_scale needs an id and does not change layout.
  • Value animations go to a declared target. fade = {target, ms} animates opacity, fade_in = ms fades in on first appearance, slide_in = {{dx, dy}, ms} slides in from an offset. opacity is the static version with no tween.

Progress is kept per element id — which is why an animated element needs a stable one. Retargeting mid-flight reverses smoothly instead of jumping: kanban's drop guides are fade = { on and 1 or 0, 140 } on an id that never changes, so the line fades in and out as the pointer moves. on_faded_out fires when a fade completes — the hook for removing an element after it fades away.

Authored simulations are the deliberate exception: on_frame(e) runs Lua after each presented frame and schedules the next while declared — so declaring it is the repaint request, and removing it is how repainting stops. Read e.dt and e.elapsed by name; see §Handlers for the full event. Keep transient positions and velocities in module locals, perform no document writes per tick, avoid allocations in inner numeric loops, and remove the handler when settled. Routine UI effects must use the declarative transitions above.

Corrected 2026-09-20: this paragraph read on_frame(dt, elapsed) — the old positional form — while §Handlers documented the named-field event. Following it bound the wrong values silently and kept running, which is exactly the failure §Handlers warns about. Logged as gap-log 1.3 before being fixed, because a guide that contradicts itself is the authorability finding six-apps.md exists to measure, and patching it in passing would have destroyed the evidence.

Patterns from kanban

The kanban app is the canonical example — no other app exercises all of this. Pattern → where to look:

pattern file
typed drags — on_drag updates S, on_drop computes placement, one commit at "release"; one drop target, two meanings (card into / column beside) main.lua + model.lua
the drag ghost — rebuilt at the root, absolute + top/left in viewport coords, no id and no handlers (a duplicate id would collide), the original dims via opacity main.lua
drop guides always rendered — the space is reserved, only the alpha animates, nothing shifts when a target appears ui/widgets.lua
resize — a grip in the gutter, live width from viewer state, one write on release — the full pattern below model.lua + main.lua
modal — a root-level absolute scrim with rgba fill and on_click to dismiss; the panel swallows clicks with on_click = function() end and appears with fade_in main.lua
composer — ui.input + button, draft in ui.state("draft:" .. col_id), cleared on send main.lua
spacer — ui.col({ grow = true }) pushes what follows to the far end throughout

Resize, in full

Column resizing is achieved and shipping in kanban, and it's the most instructive pattern in the corpus: all three state homes in a few lines.

-- model.lua — the drag lives in viewer state, the width lives in the document
function actions.resize(msg)
	if msg.phase == "start" then
		local c = find(board.columns, msg.id)
		local w = c and c.w or C.col_w
		S.resize = { id = msg.id, from = w, w = w }
		return
	end
	if not (S.resize and S.resize.id == msg.id) then return end   -- a stale drag
	if msg.phase == "move" then
		local w = S.resize.from + msg.dx
		S.resize.w = math.max(C.col_w_min, math.min(C.col_w_max, w))
	else -- "end"
		board:set({ "columns", msg.id, "w" }, S.resize.w)
		S.resize = nil
	end
end

-- main.lua — this frame's width: the drag's live value, else the stored one
local function width_of(c)
	if S.resize and S.resize.id == c.id then return S.resize.w end
	return c.w or C.col_w
end

What to steal from it:

  • Clamp on every move, never only at the end — an end-only clamp lets the pointer walk past the limit and the column sits dead until it comes back.
  • During the drag, layout reads viewer state; the document is untouched. A drag is sixty frames of S.resize.w and one :set at "end" — one document write per finished gesture, which is also what a future peer receives: the commit, not the drag.
  • The write needs no guard — an unchanged value is a no-op in the document.
  • min_w/max_w on the element are the layout guard — a second, separate job from the drag clamp. A width can arrive that never passed the clamp: from a peer whose theme differs, from a hand-edited document. The drag clamp is policy; the bounds are physics.
  • The grip is a sibling in the gutter, not a child of the column — it must sit between two columns, and a child is clipped by its parent's rounded panel.

App structure

The kanban split, worth copying at any size:

  • theme.lua — palette and geometry constants; depends on nothing.
  • model.lua — the document, guarded seeds, and the actions table (the update half of Elm); shared interaction state is a field on the exported table — a local rebind is invisible across require, a table field is not.
  • main.lua — the view; owns everything that reads or writes pointer state, so the read and the write stay in one file.
  • ui/widgets.lua — pure functions of their arguments; handlers are passed in, never reached for.

The sandbox, and what happens when you err

Your code runs sandboxed: no io, no filesystem, no network, no os — and require can only see your own folder. Available beyond plain Lua: doc, ui, require, search, now() (unix seconds as a float, wall clock), uuid(). A runaway loop is killed, with the line number.

now() is for recording when something happened — a created-at, a last-edited. It is not for measuring how long something took: it follows the system clock, so it can jump, including backwards. Anything timing a gesture or an animation wants the monotonic clock instead, which reaches Lua as e.t on on_drag and e.elapsed on on_frame.

Errors are for reading, not for fearing:

  • An unknown prop or a value of the wrong type is an error at that element — a red box in place, siblings stay alive.
  • A gfx call that is missing a required field names it: frame needs width, fill needs brush, stroke needs width. Passing the wrong handle — a brush where a path goes — says so too: fill.path must be a gfx.path.
  • A view that throws keeps the last good frame with an error banner naming file and line.
  • Nothing you write in a handler can take the app down for good; fix the file and it reloads.

The fastest agent authoring loop is: upload once, then use the Python bridge client's read_file_versioned and edit_file methods for exact edits to existing source. Inspect the rendered tree and console after activation, then repair against the new revision if needed. WriteFile is for initial upload, file creation or an explicit wholesale replacement—not the normal edit loop. Keep files small enough that a reported line number means one obvious thing.

App-shipped tests

An app may include tests/*.lua beside main.lua. The first built slice runs those files over the bridge in a separate sandboxed test VM with t.expect(cond, message), t.step(frames), t.world(), t.rects(), t.centre_of(id), t.click_at(x, y), t.text(id) and t.type(id, text) (sets an input's text, as typing would), after opening a temporary non-persisting app tab with empty docs. That tab gets its own in-memory search index, so an app can test its search (demo_apps/chat/tests/search.lua). The planned behavioural runner will add keyboard helpers while still keeping tests outside the app VM; see docs/design/lua-app-tests.md.

Checking it without a window

The shell runs windowless: shell2 --offscreen WxH is the real shell — real layout, real pixels, real Lua — with nowhere to present the frame. Drive it over the bridge from Python.

from osvauld.session import Session, shell_binary

with Session(shell_binary=shell_binary(), offscreen=(900, 700)) as s:
    s.rpc.signup("me", "passphrase")
    ws = s.rpc.create_workspace("scratch")
    item = s.rpc.create_item(ws["id"], "pie", "app")["id"]
    s.rpc.upload_folder(item, "demo_apps/pie")
    s.rpc.open_item(item)

    source = s.rpc.read_file_versioned(item, "main.lua")
    result = s.rpc.edit_file(item, "main.lua", source["revision"], [
        {"old_text": '"Where visits come from"', "new_text": '"Traffic sources"'},
    ])
    assert result["persisted"] and result["activation"] == "activated"

    s.rpc.dump_tree(item)              # the El tree: ids and handlers, no rects
    s.rpc.click(item, "legend:search") # by id — calls the handler, no hit-test
    s.rpc.rects()                      # ['pie', 'legend:search', …] and where they are
    s.rpc.click_at(*s.rpc.centre_of("legend:search"))  # by coordinate, through the hit-test
    s.rpc.keyboard("KeyW", "w", True)  # physical code, logical key, down
    s.rpc.frame(3)                     # hold across three driven frames
    s.rpc.keyboard("KeyW", "w", False) # release (distinct from input's Key-by-id)
    s.rpc.frame(250)                   # time passing
    s.rpc.save_screenshot(item, "out.png")
    s.rpc.read_console(item)           # errors, newest last

OSVAULD_OFFSCREEN=900x700 makes every script in scripts/ windowless without editing it, and python3 scripts/smoke.py runs the smokes. print() from a handler reaches read_console.

Never guess a coordinate. rects() answers with every reachable element and the rect a pointer must land in — the clipped rect, which is what the hit-test tests, so an element scrolled half out of view reports where it can actually be hit rather than where its layout box is. An element that is fully clipped is absent, because it cannot be hit at any coordinate. dump_tree says what exists; rects says what is reachable. Coordinates are logical points from the top-left, the same units an element's rect is in.

The pointer ops report what is under the pointer afterwards, so a miss reports as a miss. This is the difference that matters: an app that did not change tells you nothing about whether you were 5pt out or 200.

Click by id and click_at by coordinate are not the same test. The first calls the handler directly; the second goes through on_cursor_moved / click / on_cursor_release — the same methods a window calls, with hit regions, shape hits, drag slop and eligibility all in play. Use the id form to drive an app, the coordinate form to test that it is touchable.

Offscreen the clock is virtual and driven: nothing moves until a request asks. A frame is 1/60s and a pointer event lands 8ms after the frame it is tested against, whatever the machine actually took — so frame(250) is a little over four seconds of app time, on every machine, every run. advance(secs) jumps instead, then paints once so the app notices: a 25-minute timer finishing is one request, not 90,000 frames.

Two behaviours are easier to see here than to reason about: a press that travels more than 5pt is a drag and fires no click, and a press that travels less is a click reported at the point it was released. Since no hand is perfectly still, the second is the ordinary case.

What this still cannot tell you: it needs a DISPLAY even though it shows nothing (windowless, not headless), and a view that passes here can still look wrong — check the screenshot.

Rewritten 2026-09-20. This section taught cargo run -p app_host --example open, a second driver that only worked from a source checkout and had no ids, no screenshots and no hot reload. The bridge now does everything it did; see design/six-apps.md §7 for why there is one driver.