Builtin Library
README.md
touchControls
On-screen touch overlay: a renderer + worker, not an event-driven widget net.
The worker reads raw touches (Zin.touch.slots()) and routes them into zone
geometry derived from the live bindings, writing Zin.virtual
(stick/button/drag). Zin.tick runs the worker early — after
Virtual._beginFrame() clears the previous tick's drag deltas and before the
binding evaluators read Zin.virtual — so the touch binding kinds consume
those values on the SAME tick they're written.
The renderer (a canvas screen declaring pointerEvents = "none") only
draws and takes no pointer, so every widget beneath it, in any screen at any
layer, still receives the presses that land on it. Backs Zin.touchControls.
Who a contact belongs to
- A contact that begins on a drawn button, the fan or an open sheet entry is
that control's alone. The overlay states those circles to the input layer
with
input.setTouchClaims(in the window's pixels, where the input layer meets a contact as it arrives), and a contact beginning inside one drives no pointer: no widget beneath the button is pressed by it, and no mouse button reads held. - A contact that begins anywhere else drives the pointer as the primary contact always does, and the UI decides whether a widget takes its press. When one does, the contact is the widget's: on the tick after it began, the stick or look zone that took it lets it go before it has moved anything.
- Everything left falls to the stick and look zones below.
Coordinate space
Touch contacts (Zin.touch.slots() — t.x/t.y/t.dx/t.dy) carry
PHYSICAL pixels of the scene viewport the overlay draws over, measured from
its top-left: the space Zin.state.get().mouse_position reads the pointer
in. Every
widget geometry computation — button/stick positions, the canvas root's
style.width/height — lives in ui.screenSize()'s LOGICAL space (the space
ui.registerScreen / ui.getLayoutInfo use), which differs from the
physical space whenever the display's pixels_per_point ~= 1 (HiDPI). An
internal logicalScale helper computes the screenSize / viewportSize
ratio once per tick, and every touch coordinate this module reads is scaled
by it before use.
Safe area
Every control keeps inside the part of the overlay no system bar, display
cutout or on-screen keyboard covers. ui.safeArea() states that part of the
picture the overlay's screen lays out in (the scene viewport's, at
ui.screenSize()), and the overlay takes it into its own space, which leaves
a safe rect L/T in from the left and top edges, SW x SH in size (the
whole picture where the picture lies inside the window's safe area, as a
Scene panel does). The zones, the button column and the
stick all lie inside it, a contact beginning outside it starts none of them,
and layout().safe reports it.
Size on the glass
Every radius, inset and gap is in the points ui.screenSize() reports. On a
phone or a tablet a point covers at least the display's density
(ui.displayScale()), so the controls keep one physical size on every phone
and in both orientations: a 40 point "small" button is 80 dp across, past
the 48 dp touch target. layout() reports the radii in points; multiply by
the pixels one of the overlay's points covers for their size on the glass:
the width ui.getLayoutInfo("zin_touch_controls_canvas") gives its canvas,
over layout().safe.width, times ui.pixelRatio(). Button labels are canvas
text sized from the button's radius, so the user's text scale leaves them
inside their circles.
Zone geometry (fractions of the safe rect, SW/SH)
- stick - activation region
x < L + 0.4*SW and y > T + 0.4*SH(left 40%, bottom 60%). A floating stick: the base spawns at the touch's start point, the thumb clamps to a 96px radius, and the vector fed toZin.virtual.setStickisoffset / 96withyscreen-down positive (matchingsetStick's documented convention). - buttons — one circle per
touchButtonbinding among the LIVE bindings (Zin.scheme.bindings()— what awake components asked for, so every button corresponds to a control some world actually consumes), budgeted into a two-column band along the safe rect's right edge, between 45% of its height and its bottom edge less the inset. Buttons sort by(priority, id)- the touchButton'spriorityoption, authored default 50, synthesized default 100 — with same-groupbuttons kept adjacent, then fill the two columns growing inward from the bottom-right corner. Each circle is the radius ITS OWNsizeoption names (small40px,medium56px,large72px), so a scheme mixing alargeaction button with asmallpause pip draws them at 72 and 40 whatever else is live; the largest of those radii is the slot pitch and the row budget the column packs on, so the set decides where a button sits and the button's own declaration decides how big it is. More than 4 authored buttons walk every button one step down its own size, whichlayout()reports asappliedSize. Anything past the two-column budget doesn't render individually — the last slot becomes a...fan button; tapping it opens a rows-of-3 grid sheet holding the overflow (same tap/hold behavior as a regular button), and tapping the fan again, or any layout-invalidating context/map change, closes it. - drag - everything else in the safe rect at
x >= L + 0.4*SW(accumulates intoZin.virtual.addDragevery tick from the contact's per-frame delta). - the remaining top-left quadrant (
x < L + 0.4*SW and y <= T + 0.4*SH) is unclaimed - free for other UI.
A control is drawn only when it is live AND subscribed: a button offered for
a control nothing listens to is an offer that cannot be kept, so the layout
tracks Zin.scheme.subscriberEpoch() alongside Zin.scheme.generation().
Only bindings whose context matches the current input context
(Zin.context.current()) contribute controls, so a vehicle-context Brake
button neither renders nor claims touches while the default context is on
top. The layout rebuilds when the live set, the subscriber count, the
context, the screen size or the safe area changes.
A record-shaped map applied through Zin.map.activate contributes its touch
classes too. A control whose name is already live as an asset is skipped, so
a world carrying both never draws the same control twice under two ids.
A contact is assigned to a region on its began phase only (a contact-id ->
assignment map) and released on ended / cancelled / vanishing from
Zin.touch.slots(). Button circle hit-tests (visible buttons, the fan, the
open sheet's entries) take precedence over the stick/drag zone checks — a
button is a precise target and wins wherever it lands, including inside a
zone (the open sheet grows leftward and can reach the stick zone on a narrow
screen). A contact claimed by the stick or a button never also feeds the
drag zone (a contact is assigned to exactly one region), so its
projected-mouse / touchDrag state never competes with a look/aim consumer
reading Zin.virtual.drag — no extra suppression is needed beyond the
region assignment itself.
A pinch and the drag region share the same space, so the two are arbitrated
once per tick. The contacts the stick and the buttons hold are published to
Zin.gestures, which forms its two-finger pair out of what is left; while
the active map declares a touchPinch control and that pair is down, the
drag region reports nothing. A pinch made over the look surface therefore
reaches the pinch control alone, a thumb resting on the movement stick is no
part of a pinch so the other thumb keeps looking, and lifting one finger from
a pinch drags again.
Rendering
One screen ("zin_touch_controls", layer 200) whose root is a single
transparent canvas sized to the safe rect, taking no pointer. The screen
lays out inside the safe area, so the canvas starts at the rect's corner
and every command is drawn from there. Commands rebuild
only when the draw state actually changed (a dirty flag) — buttons are
always drawn while mounted; the stick draws only while a stick contact is
live.
Auto-mount
Every _advance() checks Zin.surface.current() — "touch" registers
(lazily, once) and shows the screen; anything else hides it. Visibility is
purely the surface class, in edit and play alike. M._forceMount(true)
bypasses the surface gate for headless testing. Touch routing itself is
unconditional: it runs every tick regardless of mount state, so a forced or
real touch always drives Zin.virtual even before the overlay is visible.
Adding a control while the world runs
The overlay draws a button for a control that is live and subscribed, so
adding one at runtime is adding a control asset, activating it, and
subscribing to it — the three steps a component takes in awake, in one
call:
local h = Zin.touchControls.addControl({
name = "cast", label = "Cast",
kbm = Zin.bindings.key("KeyF"),
gamepad = Zin.bindings.padButton("north"),
onPressed = function() castSpell() end,
})
Zin.touchControls.removeControl(h)
The control becomes a real <name>.inputMap/<name>.inputBinding/ under
/source/runtimeControls/, so it is inspectable, rebindable and publishable
like any other, and its name is its id in Zin.scheme.bindings() /
Zin.scheme.fired(). removeControl releases it — off the screen, out of
the live set, every subscription dropped — and leaves the asset, so
re-adding the same name reuses it and its guid.
M.button(opts) is the same idea against a record-shaped map applied through
Zin.map.activate, in two flavors:
{ action = "jump" }attaches an extra touchButton binding to an existing action on the active map.{ emit = "KeyF", label = "Cast" }registers an overlay-only button: a syntheticemit:KeyFaction carrying both the kbm key binding and the touchButton, so the emulation floor drivesKeyFthrough the same binding/emulation path every other control uses.
Both return a handle for M.removeButton(handle).
Exports
M.addControl(opts: ControlOpts) -> number/M.removeControl(handle) -> boolean— add or release a control while the world runs.M.controls() -> { { handle, name, path } }— every controladdControlcurrently holds, in the order they were added.M.button(opts) -> number/M.removeButton(handle) -> boolean— the button API described above.M.claimed() -> { number }— the contact ids currently claimed by the stick, a button, or the drag zone.M.primaryClaimed() -> boolean— whether an on-screen control owns the primary contact.Zin.bindingsconsults this so a thumb on the stick or the look area does not also fire whatever the world bound to left-click.M.layout() -> { buttons, fan?, stick?, drag?, pinch?, width, height, safe }- the current control layout (a copy):buttonsis the budgeted, VISIBLE stack in draw order, each entry{ id, label, actionName, priority, size, appliedSize, group, cx, cy, radius }inui.screenSize()space —sizeis the step the binding declared andappliedSizethe oneradiuscame from, which differ only where a rich scheme's step-down moved a button one size down;fanis nil unless the button set exceeded the two-column budget, else{ open, cx, cy, radius, buttons }—openis whether the overflow sheet is expanded,buttonsthe overflowed entries at their sheet positions (tappable only whileopen);stick/dragname the axis + virtual zone each region feeds, andpinchthe axis the two-finger recognizer feeds;safeis the safe rect{ x, y, width, height }every control keeps inside. Backs custom control-placement UI and headless tests that aim simulated touches at a button's (or the fan's) center.M._advance()— internal: one tick (touch routing, layout, auto-mount, repaint). Wired intoZin.tick, beforeZin.emulation._advance().M._forceMount(on: boolean)— test-only: force mounting regardless ofZin.surface.current().M._setViewportOverride(w?, h?)— test-only: override the logical screen size the layout budget/fan math reads, without resizing the real window. Pass nil/nil to clear.M._reset()— test-only: unmount, clear routing/layout state, drop buttons, release every runtime control, close the fan, and clear the viewport override.