Input
Input is the bridge from raw device events to the render tree’s handlers. The runtime polls SDL for mouse, wheel, and keyboard events and feeds them to a set of…
Input is the bridge from raw device events to the render tree’s handlers. The runtime polls
SDL for mouse, wheel, and keyboard events and feeds them to a set of routers; the
routers hit-test the tree (for pointer events) or consult the focus owner (for key events)
and fire the matching handler. Like layout, all of this is pure Scala — it needs only
hitTest, the parent chain, and the handler maps — so the dispatch model is unit-tested on
the JVM.
Bubbling
Dispatch bubbles. A hit-test lands on the deepest object under the cursor, but handlers are usually registered on a composite’s outer object — a button’s frame, a slider’s track — while the cursor actually sits over an inner decoration with no handler of its own.
So an event walks up the parent chain from the hit to the nearest ancestor with a handler for that event, and fires there. This is the analogue of DOM event bubbling, in the single-listener-per-object form suit uses.
The event’s coordinates are resolved against the receiving object, not the raw hit:
final case class PointerEvent(position: Offset, local: Offset, size: Size, button: Int = 0):
def x: Double; def y: Double // absolute (window) coordinates
def localX: Double; def localY: Double // relative to the receiving object's top-left
positionis the absolute window coordinate.localis that point relative to the receiving object’s top-left, andsizeis that object’s size.
Together they let a handler work in its own coordinate space — a slider maps
local.x / size.width to a fraction, measured against its whole track no matter which
inner pixel was hit.
Pointer capture (drag)
A press captures the pointer at the hit object. While captured, every subsequent move
goes to the capturing object — even after the cursor leaves its bounds — until the button
comes up. This is what makes dragging work: a slider keeps receiving mousemove while you
drag past its edge. During a capture drag, button carries the held button so a handler
can tell a drag from a plain hover (if e.button != 0 then …).
A click fires on button-up only if the press and the release resolve to the same click
handler — i.e. the release landed on the widget the press started on. Pressing a button
and releasing off it does not click.
Hover
Hover is tracked per owner, not per pixel. The router finds the nearest ancestor that
takes part in hover (has a mouseenter or mouseleave handler) and fires mouseleave /
mouseenter only when that owner changes — so moving the cursor across a button’s inner
label does not spam enter/leave, and a widget styles itself on hover cleanly.
Keyboard focus
FocusManager owns which object currently has keyboard focus:
- A press routes focus to the nearest focusable ancestor of the hit object (the target itself if it is focusable), or clears focus if the press landed on nothing focusable. Clicking a widget takes focus; clicking empty space drops it.
- Focus changes fire
focus/blurhandlers (no payload) so a widget can re-style itself. KeyRouterdeliverskeydown/keyuponly to the focused object.
Mark an object focusable in the DSL with focusable = true; the built-in widgets set it on
their outer object.
Tab traversal
The runtime intercepts Tab (and Shift+Tab) before key routing and moves focus through the focusable objects rather than delivering the key to the focused widget. The order is the tree’s document order — a depth-first walk, each object before its children — and it wraps at the ends: Tab past the last focusable returns to the first, Shift+Tab past the first goes to the last. With nothing focused, Tab takes the first focusable and Shift+Tab the last.
This is FocusManager.focusNext(root, backward) over FocusManager.focusables(root), both
pure, so traversal order is unit-tested on the JVM. Because Tab is consumed by traversal, a
focused TextField does not receive it as input — it advances focus, as in a web form.
final case class KeyEvent(scancode: Int, repeat: Boolean = false, shift: Boolean = false, ctrl: Boolean = false)
scancode is the physical key as a standard USB-HID usage code — a stable
cross-platform numbering, not an SDL detail. The Key object names the common ones:
Key.A // letters are USB-HID 4..29 (a..z); only the ones widgets use are named
Key.Enter Key.Escape Key.Backspace Key.Tab Key.Space
Key.Left Key.Right Key.Up Key.Down
Key.Home Key.End Key.Delete Key.PageUp Key.PageDown
repeat is true for the auto-repeat events a held key produces. shift / ctrl report the
modifiers held at the time — the runtime fills them from the event’s modifier bitmask — which
is how a text field tells a caret move from a selection extend, or recognises Ctrl+A.
Text input
Editing keys are scancodes, but the characters they sit between are a separate stream:
the layout-resolved Unicode a key press produces (so a shifted or composed character arrives
as the actual string). The runtime opens the platform text-input session whenever focus lands
on an object that sets acceptsText = true, and delivers each typed run as a TextInputEvent
to that object’s onTextInput handler via TextRouter.
final case class TextInputEvent(text: String)
This is what TextField is built on: editing keys come through onKeyDown, the typed
characters through onTextInput.
Wheel
A wheel turn bubbles a ScrollEvent to the nearest wheel handler at the cursor.
final case class ScrollEvent(
position: Offset, // window-absolute cursor position
deltaX: Double,
deltaY: Double,
local: Offset = Offset.zero, // cursor within the receiving object
size: Size = Size.zero, // the receiving object's size
shift: Boolean = false,
ctrl: Boolean = false,
meta: Boolean = false,
alt: Boolean = false,
):
def consume(): Unit
def consumed: Boolean
def localX: Double
def localY: Double
Positive deltaY is a downward/away scroll, matching SDL’s convention.
local and size are resolved against the receiving object — the same pair
PointerEvent carries — so a handler knows where in its own box the cursor sits without
subtracting absoluteOffset itself. Because the event chains (below), each receiver gets a
fresh event with its own local mapping. This is what lets a custom surface anchor a zoom on
the cursor: read localX/localY, scale around that point, and the content under the pointer
stays put. shift / ctrl / meta / alt report the modifiers held at the turn (ctrl || meta
is the conventional cross-platform “primary”), so a surface can give the plain wheel and a
modified wheel different jobs — the universal scroll-versus-zoom split.
Chaining
Unlike the other pointer events, a wheel event does not stop at the first handler that sees it: it chains. The nearest scrollable under the cursor is offered the event, and if it does not claim it, the event passes on to the next scrollable ancestor.
A handler claims the event by calling consume(). The built-in views claim it only when
they actually moved — so a view already at its end, or a list too short to scroll, leaves
the wheel unclaimed and the view outside it scrolls instead. Without this a cursor resting
on a short inner list would silently kill the page scroll under it, which reads to a user
as the window having frozen.
This mirrors what a browser does with an exhausted inner scroll: the page keeps scrolling.
A custom onWheel that handles the wheel itself — a zoom, say — should consume() it, or
the scroll view it sits inside will act on the same turn:
box(onWheel = e => {
if e.ctrl || e.meta then // modified wheel zooms, plain wheel falls through to scroll
zoomAround(e.localX, e.localY, e.deltaY) // anchor the zoom on the cursor
e.consume() // stop it here; do not also scroll the page
})(...)
Cursor
A widget names the pointer shape shown while the cursor is over it with the cursor prop,
a value of the Cursor enum:
box(cursor = Cursor.Pointer)(...) // the hand, over anything clickable
Cursor is a semantic set — Pointer, Text, Crosshair, Move, NotAllowed,
Progress, Wait, and the four resize arrows ResizeEW / ResizeNS / ResizeNESW /
ResizeNWSE — that the native runtime maps to the platform’s own system cursors, so a link
reads as the OS hand and a text field as its I-beam.
The shape resolves like the text-style cascade: the runtime shows the Cursor of the
nearest object at or above whatever is under the pointer that names one, so a Button sets
Cursor.Pointer once and its inner label inherits it. A widget that names no cursor (the
default) leaves the shape to its surroundings; with nothing named anywhere, the arrow shows.
Cursor.Default is a real preference — it forces the arrow, overriding an inherited shape —
distinct from naming nothing, which inherits.
During a drag the shape sticks to the widget the press started on, so a splitter keeps its resize arrow while the pointer strays off the thin gutter. The runtime re-resolves every frame, so a shape also follows a layout change under a still pointer, not only a move.
The built-in widgets already carry sensible cursors: Button / Checkbox / Slider show
the hand, TextField / TextArea the I-beam, and a splitter its resize arrow.
Handlers on box
All of this is reached through the typed handlers on the box builder:
box(
focusable = true,
cursor = Cursor.Pointer, // pointer shape while hovered
onClick = (e: PointerEvent) => ...,
onMouseDown = (e: PointerEvent) => ...,
onMouseUp = (e: PointerEvent) => ...,
onMouseMove = (e: PointerEvent) => ...,
onMouseEnter = (e: PointerEvent) => ...,
onMouseLeave = (e: PointerEvent) => ...,
onWheel = (e: ScrollEvent) => ...,
onKeyDown = (e: KeyEvent) => ...,
onKeyUp = (e: KeyEvent) => ...,
acceptsText = true, // open text input while focused
onTextInput = (e: TextInputEvent) => ...,
onFocus = () => ...,
onBlur = () => ...,
)(children*)
The widget library is built entirely on these — reading the
Button, Checkbox, and Slider sources is the best way to see the model in practice.