Skip to content
zabloo

Input & focus

Hit-testing, focusability, directional navigation, the keyboard and the gamepad — owned by the SDK, derived from the layout, and authored nowhere.

This page is about what happens when a finger, a mouse or a d-pad meets the UI. Open the quest log on a controller: one Track button is already highlighted, pressing down moves the highlight to the next row, pressing A presses it. None of that was authored. There is no focus map in the envelope, no list saying which control sits next to which, and no tab order — the SDK works it out from where the boxes actually landed on screen.

That is the through-line: because the SDK computes the geometry, it also owns hit-testing (which node a point belongs to), focus (which control is currently the target of the keyboard and the pad) and directional navigation (which control a direction moves to). A UI navigates with a gamepad because of what it is, not because someone wired it.

Hit-testing

Hit-testing answers one question: the player touched this pixel — whose is it? The answer has to be the same on every target, so it is defined against the layout, not against what was painted.

A node’s input region is its layout rect. Not its painted geometry — which is why the format keeps the invariant that nothing paints outside its rect: the two would otherwise disagree, and the disagreement would be invisible.

The resolution order for a point:

  1. The overlay layer, top-down. Overlays are tested before the tree, in reverse layer order. A modal overlay captures the point: everything below it — lower overlays included — is unreachable, which is what makes a backdrop a backdrop. A non-modal overlay only takes the point if it lands on one of its children.
  2. The tree, deepest node first, later siblings winning over earlier ones, since they paint last.

Clipping cuts input. A node’s effective clip is the intersection of every ancestor clip, and a point outside it prunes that whole subtree — a row scrolled out of a ScrollView is neither drawn nor tappable. Overflow that is not clipped stays reachable: only a clip cuts input, exactly as only a clip cuts paint.

Corner radius is respected. A clip’s rounded corners cut the input region too, so a tap in the corner outside a rounded panel falls through to what is behind it.

Once a node is hit, the event is attributed to the nearest focusable ancestor: a tap on the Text inside a Button is a tap on the Button.

Cancelled gestures

A player who starts pressing Buy and gets interrupted must not be charged for the sword. Every SDK has to agree on that, so what a broken-off gesture does is part of the contract rather than each target’s own guess.

normative

A pointer does not always end in a release. A touch can be interrupted by the system, a browser gesture can take the pointer away, a device can be unplugged mid-press. Every gesture in flight ends, and none of them concludes:

In flightOn cancel
A press on a Button or ToggleReleased without activating — no action, no value moves.
A drag on a ScrollViewStops; the offset it reached stays.
A selection drag in a TextInputStops; the selection it reached stays.
A press on a modal's backdropNothing is dismissed.
A Slider dragSettles: onCommit fires.

The Slider is the one exception because its value is already on screen and was written into its bound path on every move: refusing the commit would leave the game without the “apply the expensive thing” event for a value the player really did leave there. The others had produced nothing yet, so producing it now would be inventing an intention.

This is the same rule as a press released outside its control, and it applies to the pad: a controller unplugged mid-press cancels it, and a Slider it was nudging settles.

Focusability

Focusable means the keyboard and the pad can land on it — a control the player can reach and press. It is not something you switch on: it follows from what the node is, so the same node type is focusable in every UI, in every target. That is what makes a screen navigable without anyone auditing it.

Focusability derives from component identity. There is no focusable prop:

normativeFocusability by node type

Node typeFocusable
ButtonYes
ToggleYes
SliderYes
TextInputYes
CollapseIts header (children[0]) is; the Collapse itself is not.
Container, Text, ImageNo
ScrollViewNo — scrolling is a gesture, not a control's own interaction.
OverlayNo — it is a layer, and its children are the candidates.
Repeat, ProgressBar, SpinnerNo

Hover lights up exactly the same set. What takes input is what may look different under the pointer, so a pointer over a plain Container hovers nothing, and a mouse and a gamepad see the same set of live controls. This is what lets a tooltip triggered by hover reach a controller without a second mechanism — on a pad, focus is hover.

disabled

Switching off a section of UI — a shop row the player cannot afford, a settings group that needs a checkbox first — has to switch off everything in it, and it has to do so identically everywhere. So disabled is spelled out here in full: one prop, and everything that follows from it.

normative

disabled is the one exception to the rule above: a node that carries it is not focusable whatever its type is.

{ "type": "Container", "disabled": { "bind": "settings.custom" }, "children": [] }

It inherits. The effective value of a node is its own disabled or any ancestor’s, the way a clip is the intersection of every ancestor’s. One prop on a section therefore switches off the form inside it — which is the case it exists for — and an Overlay restarts the chain, because a layer entry is the top of its own input scope: a modal declared inside a disabled panel is still operable, and still dismissable.

Everything the interaction model does with it follows from being out of the focusable set:

  • No focus, no hover. It is not a navigation candidate, so the arrows and the d-pad walk past it, and the pointer lights nothing up.
  • No press, no action. Neither a tap nor Enter nor the gamepad’s A activates it, so no named action fires and no value moves. A press that lands on it falls through to whatever is behind: a dead Button inside a ScrollView does not swallow the drag that scrolls the list.
  • What it held, it releases. A control the game disables while it has the focus, the hover or a finger on it loses all three. The focus goes to nothing rather than to a neighbour — the player did not ask to move — and a Slider gesture in flight is cancelled, not committed: the value never settled.
  • A disabled section is still readable. Scrolling is not an interaction a control owns, so a ScrollView inside one keeps working. A player who cannot use a panel must still be able to read it.
  • The game is not blocked. The host channel is out of band, like a SetData on a bound path: SetValue and SetScroll still reach a disabled node. What disabled describes is what the player can do.

It styles nothing by itself. There is no built-in dimmed look, exactly as for every other state — what a disabled control looks like is states.disabled.style, and a node with no override declared paints unchanged. See Style › States.

Initial focus

autofocus: true marks the node that takes focus when its scope opens. The first one in document order wins.

A popover is the exception: it opens on its selection — the checked option of the group inside it, so a list of twenty languages opens where the player left it — falling back to the subtree’s autofocus, and then to its first focusable. A menu the player opened is a menu they are in, and one that starts with no focus could not be walked with the arrows at all. The list also scrolls to whatever it lands on.

Directional navigation

The player presses down on the d-pad. Which control lights up next? Every SDK has to answer that the same way, or the same screen would walk differently on a console than on a phone — so the answer is an arithmetic rule over the rectangles, not a heuristic each target invents.

normative

Focus moves on one axis at a time — up, down, left, right — and the target is computed from the live layout rects, so it survives a relayout, a hot-update, or a Collapse that just changed the shape of the screen.

For a direction (dx, dy) and the currently focused node, over every focusable candidate in the current scope:

  1. Take the vector between the two rects’ centers: (deltaX, deltaY).
  2. projection = deltaX·dx + deltaY·dy — how far the candidate lies in the direction of travel. A candidate with projection <= 0.5 is discarded: it is not that way.
  3. orthogonal = |deltaX·dy| + |deltaY·dx| — how far off-axis it sits.
  4. score = projection + orthogonal · 2. The lowest score wins.

Worked example — down the quest log. Focus sits on the Track button of the first quest, and the player presses down, so (dx, dy) is (0, 1). The Track button one row below has its centre 72 px lower and dead in line: projection is 72, orthogonal is 0, score 72. The one two rows below scores 144. And a “Show all” link 40 px down but 200 px to the left scores 40 + 200·2 = 440, so it loses to the row below even though it is physically nearer.

Weighting the orthogonal distance double is what makes the nearest control in that direction win over a slightly closer one that is mostly sideways — the behaviour a player expects from a console UI.

With no focus at all, a direction takes the scope’s autofocus, or its first focusable.

Auto-reveal. When focus lands inside a ScrollView, the scroller brings it into view. It bubbles: each scroller reveals the child of its own that contains the focus. Without it, navigation would walk into rows that are scrolled out of sight, and a pad has no wheel to go looking for them.

Focus scope and the trap

A focus scope is the set of controls a direction is allowed to reach. It matters for one case above all: when a confirmation dialog is up, the d-pad must not wander onto the buttons behind it.

The scope is normally the whole view. While a modal overlay is up, the scope is that overlay’s subtree: navigation cannot leave it, and it cannot reach anything behind it.

The trap derives from modal — there is no separate field for it. It is the same property that makes the overlay capture pointer input, because they are the same statement: this is the only thing you can interact with right now. A non-modal overlay — a toast, a tooltip — traps nothing, and its own buttons are ordinary candidates in the view’s scope.

Closing an overlay restores the focus to whatever held it before.

Focus in a virtualized list

A long list does not build every row — only the ones the viewport can show. So scrolling can delete the very node that holds the focus, and what happens then is the difference between a list that feels solid on a controller and one that loses the player’s place. It is normative because both targets have to lose and recover it identically.

normative

A Repeat only realizes the rows its viewport can show, so scrolling a list destroys the row that holds the focus. That is the renderer recycling a node, not the player giving up the focus, and the two must not be confused: the focus becomes logical and is remembered as the item it sat on — the list, the item’s identity, and where inside the row it was.

While the row is not realized:

  • Nothing wears the focus. No node paints focused, and pressing the activation button does nothing — there is no control on screen to activate.
  • The focus is not given away. It never falls back to the view’s autofocus. A focus that jumps to the other end of the screen because the player scrolled a list is a bug, and the wheel, a drag and the right stick all produce it.
  • The list keeps scrolling. The stick still moves the ScrollView the focus was in, so the gesture that pushed the row out of view is not cut halfway through it.

The row takes the focus back when it is realized again, on the same node — identity, so with a key it follows the item across a reorder. Two things end the wait instead: a direction, since the player asked to move and there is no rect to move from, so the walk starts again from the scope’s autofocus; and any real focus decision — a tap, an opening modal, the game.

Keyboard

A game UI is played with a pad, but it is built and tested with a keyboard, and the two must not drift apart. Every key below resolves to an intention the pad also produces, which is why the mapping is fixed rather than per game.

normativeKeyboard

KeyEffect
ArrowsMove focus, per the algorithm above.
EnterActivate the focused node — press a Button, toggle a Toggle, submit a TextInput.
SpacePresses the focused node, exactly like Enter — down on the keypress, activated on the release. Inside a TextInput it is a character instead.
EscapeDismiss request to the topmost modal overlay.
Text keysEdit the focused TextInput.

Enter and Space are one intention, and it is the gamepad’s A: the press lights the pressed state on the way down and activates on the way up, so a release that never comes — the focus moved, the pad was unplugged — cancels rather than fires. Auto-repeat is ignored: holding the key does not activate twice.

While a TextInput has the focus

Typing in a field and navigating a screen want the same keys, and a text field has to win the ones that edit text without swallowing the ones that leave it. The split below is exact for that reason.

A focused field claims the keys that edit it before anything above sees them; what it does not claim falls through to the ordinary handling.

normativeKeys a focused TextInput claims

KeyEffect
← →Move the caret one character; with a selection up, collapse to the edge it was pushed against rather than also stepping. With Shift, extend the selection instead. With nothing selected and the caret already at that end, the field gives the key back and the focus leaves it.
Home / EndCaret to the start or the end of the line. With Shift, select up to it.
Cmd/Ctrl + ← →Same as Home/End — and the same Shift behaviour.
Cmd/Ctrl + ASelect the whole value.
Backspace / DeleteDelete the selection when there is one; otherwise one character, backwards or forwards.
EnterFire onSubmit. It never inserts a newline: the field is one line in v1.
SpaceA character, so it does not press anything.
TabSwallowed. Navigation here is spatial, so a Tab would only hand the keyboard to whatever the page has next and leave the field still looking focused.
↑ ↓Not claimed — a field is one line, so the vertical arrows navigate out of it like anywhere else.
Cmd/Ctrl + C / X / V / ZNot intercepted — the platform's own field performs them and the edit comes back through its input event.

Two controls claim the arrows for themselves, and they do it differently on purpose:

  • A Slider takes the arrows on its own axis and never gives them back. The cross-axis ones keep navigating: stepping through a range is what those keys are for while a slider has the focus.
  • A TextInput takes the arrows to move its caret, but gives them back at the extremes: at the end of the text, one more press leaves the field. Walking out of a long string one keypress at a time is not a reasonable price, and a text field is not a place to be trapped.

Navigation is spatial, so there is no Tab order to implement.

Gamepad

The gamepad is one more source of input, not a second input model. Everything it reads resolves to an intention the keyboard already produces, and both go through the same handlers — which is what keeps “navigate with the d-pad” and “navigate with the arrows” from drifting apart.

Which is also why the mapping is normative: a player who moves from the pad to the keyboard mid-session must not find a different UI.

normativeGamepad

ControlIntention
D-pad / left stickA unit direction — the arrow keys' own move.
APress/activate the focused node (Enter). Releasing it releases the press.
BDismiss request to the topmost modal (Escape).
Right stickScroll the ScrollView the focus lives in.

Reference values, shared by every target (gamepad.ts): navigation dead zone 0.5 with release at 0.35 — the gap is hysteresis, since a stick resting on the threshold must not fire repeatedly without moving — scroll dead zone 0.15, hold-to-repeat after 400 ms at one step every 90 ms, and a scroll speed of 1100 px/s at full deflection.

The pointer’s cancel gesture applies to the pad as well: a press that ends outside the control, including a disconnected pad, cancels instead of activating.

Who the keys belong to

A view is rarely alone on screen. In the editor it sits next to toolbars and panels; on the web it shares a page with ordinary HTML. Throughout this section the host means that surrounding application — the page, the editor, the engine’s own interface — which is a different thing from the game whose UI the view is. When the player hits Enter and both could reasonably claim it, this is what decides.

normative

The pointer is scoped to its own surface by construction. The keyboard and the gamepad are not — keys are a page event and the pad is a page-wide device — so who reads them is two questions, asked in this order.

The host’s own focus comes first

Each of the host’s own controls — a toolbar, a panel, a text field — is entitled to the keys while it holds the focus. The renderer reads a key only when the host’s focus is on the view itself, or on nothing. On anything else it not only refuses to act: it must not consume the key either, because suppressing it is what stops the host from turning that Enter into a press of the button that has the focus.

“On the view itself” covers two things: the surface the view draws on, and the editable element the platform types through on the targets that need one — the web renderer’s hidden field, which has to live outside the canvas because a canvas cannot compose IME. A focused TextInput is exactly the case where keys legitimately arrive with something else focused.

The surface is focusable (tabindex on the web), so the focus can enter and leave the view the way it enters and leaves any other control, and pressing it takes the focus as well as the input.

And then, which view

When the focus points at no view in particular, more than one may be mounted, and exactly one of them owns input:

  • The first view mounted owns it, so a page with a single view behaves as if the rule did not exist.
  • Touching a view hands it over: a press anywhere on its surface, whatever it lands on. Pressing nothing in particular is still using that view.
  • Focusing a view’s surface hands it over too, so the two questions can never point at different views.
  • Disposing the owner hands it to the oldest view left, and releases the host’s focus if it was holding it.

Ownership is not derived from the host’s focus, which is why it is a separate question: a view nobody has clicked yet, on a page whose focus is on nothing, still reads the keyboard.

Everything else stays per view. Each one keeps its own focus, its own hover and its own scroll offsets — these two questions decide who hears the keys and polls the pad, not where the focus lives.

What the game drives

Focus, hover, press, scroll offset, open/checked/selected state and the text in a field are runtime state owned by the SDK. None of it is serialized into the IR, and none of it survives a reload as authored data. The game reaches it through the host channel rather than through the format.