Lesson 10
Add events, focus, and semantics
Return typed interaction from the surface to component state without coupling components to a platform protocol.
Learning goal
Return typed interaction from the surface to component state without coupling components to a platform protocol.
The feedback loop
flowchart LR
NativeInput[native or device input] --> Adapter
Adapter -->|SurfaceEvent| EventSink
EventSink -->|enqueue on owner context| Router
Router --> Target[focused or hit-tested node]
Target --> Handler
Handler --> State
State --> Runtime
Runtime --> Tree
Tree -->|new frame| SurfaceOutput uses Surface.present. Input uses a separate contract because a surface may be output-only, and input capabilities can evolve independently.
Typed events
sealed interface SurfaceEvent {
data class Command(val command: InputCommand) : SurfaceEvent
data class PointerActivate(val position: SurfacePoint) : SurfaceEvent
data class Resized(val size: SurfaceSize) : SurfaceEvent
}
enum class InputCommand {
MoveFocusNext,
MoveFocusPrevious,
Activate,
Escape,
}
enum class EventDispatch { Accepted, Full, Closed }
fun interface EventSink {
/** Thread-safe enqueue; routing happens later on the session owner context. */
fun dispatch(event: SurfaceEvent): EventDispatch
}
A platform adapter translates its native input into this vocabulary. Atlas components never parse native messages or import platform event types. Lesson 11 defines bounded-queue behavior: dispatch reports Accepted, Full, or Closed rather than silently losing events.
Target selection
flowchart TD
Event{Event kind}
Event -->|pointer| Hit[hit-test placed bounds]
Event -->|activation command| Focus[focused node]
Event -->|focus command| Move[FocusOwner]
Event -->|resize| Session[session-level resize]
Hit --> Route
Focus --> Route
Route --> Bubble[target to ancestors until consumed]enum class EventResult { Ignored, Consumed }
interface InteractiveNode {
fun onEvent(event: SurfaceEvent): EventResult
}
fun dispatchToPath(
target: AtlasNode?,
event: SurfaceEvent,
): Boolean {
var node = target
while (node != null) {
if (node is InteractiveNode &&
node.onEvent(event) == EventResult.Consumed
) return true
node = node.parent
}
return false
}
A session-level router makes targeting explicit:
fun route(event: SurfaceEvent): Boolean = when (event) {
is SurfaceEvent.PointerActivate -> {
val target = hitTestTopmost(event.position)
focusOwner.requestFocus(target)
dispatchToPath(target, event)
}
is SurfaceEvent.Command -> when (event.command) {
InputCommand.Activate ->
dispatchToPath(focusOwner.focusedNode, event)
InputCommand.MoveFocusNext -> focusOwner.moveNext()
InputCommand.MoveFocusPrevious -> focusOwner.movePrevious()
InputCommand.Escape -> dispatchToPath(focusOwner.focusedNode, event)
}
is SurfaceEvent.Resized -> {
resize(event.size)
true
}
}
Hit testing visits visually topmost nodes first. The exact propagation policy—capture, target, bubble, or some subset—is a toolkit decision. Document it before components depend on it.
Focus is retained toolkit state
stateDiagram-v2
[*] --> None
None --> First: request focus
First --> Second: Next
Second --> First: Previous
First --> None: node removed
Second --> None: surface loses focusA FocusOwner should react when nodes are removed or become ineligible; the AtlasApplier.onSubtreeRemoving callback from Lesson 05 notifies it before parent links are cleared. Focus does not belong in a transient frame. A transition updates focused state, invalidates paint on both old and new nodes, and enqueues a frame on the owner context, without painting synchronously or requiring recomposition.
Semantics describes meaning
enum class Role { Text, Button, Group }
enum class SemanticAction { Activate }
data class Semantics(
val role: Role,
val label: String,
val value: String? = null,
val focused: Boolean = false,
val actions: Set<SemanticAction> = emptySet(),
)
flowchart LR
Node --> Visual[paint commands]
Node --> Meaning[semantics]
Meaning --> Tests[queries and actions]
Meaning --> Focus[focus policy]
Meaning -. optional bridge .-> Accessibility[host accessibility adapter]An internal semantics tree enables deterministic queries and actions. A query returns an opaque handle tied to retained node identity; actions are serialized onto the owner context:
interface SemanticsProvider {
fun semantics(): Semantics
}
class SemanticsHandle internal constructor(
internal val node: AtlasNode,
val properties: Semantics,
)
internal fun AtlasNode.isAttachedTo(root: RootNode): Boolean {
var current: AtlasNode? = this
while (current?.parent != null) current = current.parent
return current === root
}
interface OwnerExecutor {
suspend fun <T> run(block: () -> T): T
}
class SemanticsOwner(
private val root: RootNode,
private val owner: OwnerExecutor,
) {
suspend fun findByRole(role: Role): SemanticsHandle = owner.run {
val matches = mutableListOf<SemanticsHandle>()
collect(root, role, matches)
matches.firstOrNull()
?: error("No semantic node with role $role")
}
private fun collect(
node: AtlasNode,
role: Role,
out: MutableList<SemanticsHandle>,
) {
if (node is SemanticsProvider) {
val properties = node.semantics()
if (properties.role == role) out += SemanticsHandle(node, properties)
}
if (node is ContainerNode) {
node.children.forEach { collect(it, role, out) }
}
}
suspend fun performAction(
target: SemanticsHandle,
action: SemanticAction,
): Boolean = owner.run {
if (action !in target.properties.actions) return@run false
if (!target.node.isAttachedTo(root)) return@run false
when (action) {
SemanticAction.Activate -> dispatchToPath(
target.node,
SurfaceEvent.Command(InputCommand.Activate),
)
}
}
}
The handle cannot outlive its session; removal invalidates handles for that subtree. This tree is not, by itself, user accessibility. A real environment needs a host bridge capable of exposing roles, relationships, values, focus, and actions.
Button composition
class ButtonNode(
label: String,
onActivate: () -> Unit,
) : AtlasNode(), InteractiveNode, SemanticsProvider {
var isFocused: Boolean = false
set(value) {
if (field == value) return
field = value
invalidatePaint()
}
var label: String = label
set(value) {
if (field == value) return
field = value
invalidateLayout()
}
var onActivate: () -> Unit = onActivate
override fun onEvent(event: SurfaceEvent): EventResult =
if ((event is SurfaceEvent.Command &&
event.command == InputCommand.Activate) ||
event is SurfaceEvent.PointerActivate
) {
onActivate()
EventResult.Consumed
} else {
EventResult.Ignored
}
override fun measure(
constraints: Constraints,
context: LayoutContext,
): IntSize = constraints.constrain(
context.textMeasurer.measure(label, constraints.maxWidth)
).also { measured ->
bounds = bounds.copy(size = measured)
needsLayout = false
}
override fun paint(builder: FrameBuilder, parentOffset: IntPoint) {
val absolute = IntPoint(
parentOffset.x + bounds.origin.x,
parentOffset.y + bounds.origin.y,
)
builder.add(
SurfaceCommand.DrawText(
SurfacePoint(absolute.x, absolute.y),
label,
if (isFocused) AtlasColor(0xFF7DD3FCu)
else AtlasColor(0xFFFFFFFFu),
)
)
needsPaint = false
}
override fun semantics(): Semantics = Semantics(
role = Role.Button,
label = label,
focused = isFocused,
actions = setOf(SemanticAction.Activate),
)
}
@Composable
@AtlasComposable
fun Button(label: String, onActivate: () -> Unit) {
ComposeNode<ButtonNode, AtlasApplier>(
factory = { ButtonNode(label, onActivate) },
update = {
update(label) { this.label = it }
update(onActivate) { this.onActivate = it }
},
)
}
The interaction mutates application state through the latest callback. Recomposition then updates nodes. Event handling does not paint directly, although focus, resize, and other toolkit-owned changes still enqueue rendering without requiring recomposition.
Visual checkpoint
Draw two parallel trees from the same nodes: one visual and one semantic. Remove decorative nodes from the semantic tree. Then trace an Activate action from a test query to state mutation and back to a new frame.
Review questions
- Why are output and input separate contracts?
- Where does focus survive between frames?
- Why is an internal semantics tree not automatically an accessibility implementation?
Recap
Adapters translate native input into typed events.
Atlas chooses targeting and propagation policy.
Focus is retained state.
Semantics expresses meaning independently of rendering.
Events update state; state drives the next composition and frame.
Finished this lesson?
Your progress stays only in this browser.