Programmable Ink Lab Notes
title
How PlayBook Processes User Input
dated
Jan-Feb '26
author
Ivan Reese

The first half of this note is basically battle scars from the wrapper, and the second half is basically “don’t do React”.

PlayBook is a web app that can run in any browser, but we use it inside a special wrapper iPad app. The iPad app captures finger motions, pencil strokes, and other physical inputs with higher fidelity than the browser APIs allow, forwarding these inputs to a web view running PlayBook. This hybrid gives us the richness and capability of a native app with the portability and rapid iteration of a web app.

All inputs to the PlayBook web app pass through three processing stages: event capture, input enrichment, and gesture routing. The rest of this note will describe the function and design of these stages.

Terminology:

Event capture

PlayBook renders at 60 Hz, but the browser or wrapper deliver events at up to 240 Hz. For performance reasons, the event capture system queues events to be processed on the next frame, rather than acting on them immediately. When PlayBook is run on a conventional computer, the event capture system also transforms mouse and keyboard input into simulated finger and pencil input.

Here’s the metadata included with each event:

type NativeEvent = {
  id: TouchId // Stable identity shared by all events that make up one touch
  type: "pencil" | "finger"
  phase: "hover" | "began" | "moved" | "ended" | "risen"
  predicted: boolean // true if the event is a hypothetical future position
  position: Position // position in screen space
  worldPos: Position // position in world space — added during input enrichment
  pressure: number
  altitude: number
  azimuth: number
  rollAngle: number
  radius: number
  z: number // Pencil hover height
  timestamp: number
}

The pencil uses all 5 phases (“risen” occurs when the pencil moves far enough away that we no longer count it as hovering), while fingers only use the middle three.

Input enrichment

By the time the next frame begins, the queue usually contains 4-6 events per touch and 8-12 for the pencil. For a handful of reasons, these events arrive in an incoherent order, so the first step is to organize them — sorted by timestamp and grouped together by touch ID.

About half of the events are real, and the other half are predicted. The wrapper generates predicted events by estimating where the next real finger or pencil event will occur, and these predictions can be used to reduce perceptual latency. In the relatively long time between rendered frames, most predictions will already be obviated by later real events. The input enrichment system filters out stale and low-quality predictions, keeping at most one prediction per touch.

This enrichment stage also fixes a few kinds of bogus data (eg: “hover” after “began” due to UIKit handling touches and hovers separately) and precomputes some commonly used derived values (eg: worldPos, which is position converted from screen space to world space).

The most important job of the input enrichment system is to maintain a struct of continually evolving metadata for each active touch:

type TouchState = {
  beganEvent?: NativeEvent // The event when the touch first contacted the screen (null when pencil hovering)
  lastEvent: NativeEvent // The previous "real" (non-predicted) event for this touch
  dragDist: number // The distance between the beganEvent and lastEvent
  drag: boolean // Has the touch moved at least a tiny bit since it began?
  // The following relate to a special "firm press" pencil motion
  averageVel: Averager
  averagePressure: Averager
  pressureSpikeAt: number | null
  firmPress: boolean
}

This struct contains state that gestures would otherwise need to track for themselves. For instance, a gesture can:

Gesture routing

Gestures are bundles of code that turn a sequence of events into specific actions. They are organized by a routing system that has a list of all the gestures supported by PlayBook — gestures for clicking, drawing, selecting, rotating, duplicating, panning, and so forth.

const gestureClasses = {
  finger: [Click, Interact, CloseSettings, OpenSettings, CloseDebug, OpenDebug, Pan, DuplicateSelection, RotateSelection],
  pencil: [Click, Interact, DuplicateSelection, MoveSelection, Draw]
}

interface GestureClass {
  // Offer an unclaimed touch to the class — it may return a instance to claim the touch
  offer?(ctx: EventContext): Gesture | void
}

Gesture classes are kept in a linear priority list, separately for finger and pencil. When a new touch begins, it’s offered to each of the gesture classes. The first to accept claims the touch and receives all its subsequent events. Gestures can do whatever they want internally — including morphing into a different gesture (eg: a pan becoming a pinch-zoom) — but they don’t need to know about each other. The only shared structure is the ordered list and the offer() protocol.

Inside the gesture, there are methods that can respond to different phases that events will have over the lifecycle of the touch.

// Instance methods for gestures
export interface Gesture {
  // Called for all events of the given phase
  hover?(ctx: EventContext): Gesture | void
  began?(ctx: EventContext): Gesture | void
  moved?(ctx: EventContext): Gesture | void
  ended?(ctx: EventContext): void
  risen?(ctx: EventContext): void

  // Called when a firm press is detected
  firmPress?(ctx: EventContext): Gesture | void

  // Called every frame
  tick?(ctx: PlaybookContext): void

  // Existing gestures get first dibs on all new touches
  offer?(ctx: EventContext): boolean | void
}

See that last instance method, offer? That’s similar to the static offer method shown previously. When a new touch begins, before offering it to the gesture classes, the system offers it to all existing gesture instances that implement this method. If any of them accept it (by returning true), that gesture becomes a de facto multitouch gesture, receiving the events from both touches.

Design

The way we handle user input has evolved considerably since our earlier experiments with Crosscut, Inkling, and earlier versions of PlayBook.

The current design has the following goals:

  1. You can write a new gesture without thinking about other gestures.
  2. Gestures can do anything they want internally.
  3. It’s easy to figure out what an existing gesture does by glancing at the code.
  4. It’s easy to figure out which gestures are active while using PlayBook.
  5. The design gently guides you toward writing gestures that satisfy the above goals.

We found it hard to satisfy these goals using the typical approaches of existing input / gesture handling systems.