diff --git a/apps/launcher/images.json b/apps/launcher/images.json index 1cd205d2..e7d6b93a 100644 --- a/apps/launcher/images.json +++ b/apps/launcher/images.json @@ -98,6 +98,12 @@ "covers/refl-im-main.png": { "linear": true }, + "covers/cover-touch-lab-main.png": { + "linear": true + }, + "covers/refl-touch-lab-main.png": { + "linear": true + }, "covers/cover-vue-sfc-lab-main.png": { "linear": true }, diff --git a/apps/launcher/registry.generated.ts b/apps/launcher/registry.generated.ts index dc21c7aa..660c7824 100644 --- a/apps/launcher/registry.generated.ts +++ b/apps/launcher/registry.generated.ts @@ -33,6 +33,7 @@ export const REGISTRY: readonly RegistryApp[] = [ { output: "music-main", id: "dev.pocket-stack.music", title: "PocketJS: Now Playing", cover: "covers/cover-music-main.png", refl: "covers/refl-music-main.png" }, { output: "settings-main", id: "dev.pocket-stack.settings", title: "PocketJS: Settings", cover: "covers/cover-settings-main.png", refl: "covers/refl-settings-main.png" }, { output: "im-main", id: "dev.pocket-stack.im", title: "PocketJS: Talk", cover: "covers/cover-im-main.png", refl: "covers/refl-im-main.png" }, + { output: "touch-lab-main", id: "dev.pocket-stack.touch.lab", title: "PocketJS: Touch Events Lab", cover: "covers/cover-touch-lab-main.png", refl: "covers/refl-touch-lab-main.png" }, { output: "vue-sfc-lab-main", id: "dev.pocket-stack.vue.sfc.lab", title: "PocketJS: Vue SFC Feature Lab", cover: "covers/cover-vue-sfc-lab-main.png", refl: "covers/refl-vue-sfc-lab-main.png" }, { output: "zoomlab-main", id: "dev.pocket-stack.zoomlab", title: "Zoom Lab", cover: "covers/cover-zoomlab-main.png", refl: "covers/refl-zoomlab-main.png" }, ] as const; diff --git a/apps/touch-lab/app.vue b/apps/touch-lab/app.vue new file mode 100644 index 00000000..9dc0641f --- /dev/null +++ b/apps/touch-lab/app.vue @@ -0,0 +1,108 @@ + + + diff --git a/apps/touch-lab/main.ts b/apps/touch-lab/main.ts new file mode 100644 index 00000000..6b59babe --- /dev/null +++ b/apps/touch-lab/main.ts @@ -0,0 +1,4 @@ +import { mount } from "@pocketjs/framework/vue-vapor"; +import TouchLab from "./app.vue"; + +mount(TouchLab); diff --git a/apps/touch-lab/pocket.json b/apps/touch-lab/pocket.json new file mode 100644 index 00000000..48553277 --- /dev/null +++ b/apps/touch-lab/pocket.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-2.json", + "pocket": 2, + "id": "dev.pocket-stack.touch.lab", + "name": "pocketjs-touch-lab", + "title": "PocketJS: Touch Events Lab", + "version": "0.6.0", + "engine": { + "capabilities": { + "requires": ["text.glyphs.baked"] + } + }, + "app": { + "entry": "apps/touch-lab/main.ts", + "output": "touch-lab-main", + "framework": "vue-vapor", + "viewport": { + "fixed": { + "logical": [480, 272], + "presentation": "integer-fit" + } + } + } +} diff --git a/framework/src/components-vue-vapor.ts b/framework/src/components-vue-vapor.ts index a307e163..a2b2568c 100644 --- a/framework/src/components-vue-vapor.ts +++ b/framework/src/components-vue-vapor.ts @@ -25,6 +25,8 @@ import { type NodeMirror, } from "./native-tree.ts"; import { createRenderRoot, type RenderRoot } from "./renderer-vue-vapor.ts"; +import type { TouchHandler } from "./touch-events.ts"; +import type { PocketTouchEvent } from "./input-api.ts"; export type { NodeMirror } from "./renderer-vue-vapor.ts"; @@ -33,6 +35,16 @@ type NodeRef = ((node: NodeMirror | null) => void) | { current: NodeMirror | nul type SlotFn = (...args: unknown[]) => unknown; type SlotBag = Record | SlotFn | undefined; +/** W3C-subset touch handlers shared by every primitive (framework/src/touch-events.ts). + * Vue templates bind them as @touchstart / @touchmove / @touchend / @touchcancel; + * JSX spells them onTouchstart etc. Both land in native-tree's touch registry. */ +export interface TouchHandlers { + onTouchstart?: (ev: PocketTouchEvent) => void; + onTouchmove?: (ev: PocketTouchEvent) => void; + onTouchend?: (ev: PocketTouchEvent) => void; + onTouchcancel?: (ev: PocketTouchEvent) => void; +} + export type VNodeChild = SolidJSX.Element | (() => SolidJSX.Element); const NO_FALLTHROUGH = { inheritAttrs: false } as const; type VaporCtx = { slots: SlotBag; attrs: Record }; @@ -46,7 +58,7 @@ const insertVaporBlock = vaporInsert as unknown as ( parent: NodeMirror, anchor?: NodeMirror | null, ) => void; -export interface ViewProps { +export interface ViewProps extends TouchHandlers { class?: string; className?: string; style?: StyleObject; @@ -56,7 +68,7 @@ export interface ViewProps { children?: VNodeChild; } -export interface TextProps { +export interface TextProps extends TouchHandlers { class?: string; className?: string; style?: StyleObject; @@ -64,7 +76,7 @@ export interface TextProps { children?: VNodeChild; } -export interface ImageProps { +export interface ImageProps extends TouchHandlers { class?: string; className?: string; src?: string; @@ -72,7 +84,7 @@ export interface ImageProps { nodeRef?: NodeRef; } -export interface SpriteProps { +export interface SpriteProps extends TouchHandlers { class?: string; className?: string; sprite?: string; @@ -206,6 +218,15 @@ function cleanProps(props: Record, omit: Set): HostProp if (key === "class" || key === "className") out[key] = normalizeClassValue(rawValue); else if (key === "style") out[key] = normalizeStyleValue(rawValue); else if (key === "onPress" || key === "on:press") out[key] = callbackOf<() => void>(rawValue); + // Touch handlers are event callbacks, not value getters: wrap with + // callbackOf so Vue's invoke-for-value semantics never call them with + // zero arguments (which would crash on ev.changedTouches). + else if ( + key === "onTouchstart" || key === "on:touchstart" || + key === "onTouchmove" || key === "on:touchmove" || + key === "onTouchend" || key === "on:touchend" || + key === "onTouchcancel" || key === "on:touchcancel" + ) out[key] = callbackOf(rawValue); // Primitive host components intentionally keep props in attrs instead of // declaring runtime prop tables. Vue therefore leaves a bare boolean // attribute as ""; on native components it still means true. @@ -265,9 +286,16 @@ function createPrimitiveNode( return node; } +function primitive(tag: "view"): (props: ViewProps) => SolidJSX.Element; +function primitive(tag: "text"): (props: TextProps) => SolidJSX.Element; +function primitive(tag: "image"): (props: ImageProps) => SolidJSX.Element; function primitive(tag: "view" | "text" | "image") { + // The implementation reads attrs (untyped fallthrough bag); the overloads + // above pin each tag's PUBLIC props contract. Runtime behavior is identical + // for all three tags — only the type surface differs. return definePocketVaporComponent( - (_props: Record, { attrs, slots }: VaporCtx) => createPrimitiveNode(tag, attrs, slots), + (_props: ViewProps | TextProps | ImageProps, { attrs, slots }: VaporCtx) => + createPrimitiveNode(tag, attrs, slots), NO_FALLTHROUGH, ); } @@ -275,7 +303,11 @@ function primitive(tag: "view" | "text" | "image") { export const View = primitive("view"); export const Text = primitive("text"); export const Image = primitive("image"); -export const Sprite = primitive("image"); +export const Sprite = definePocketVaporComponent( + (_props: SpriteProps, { attrs, slots }: VaporCtx) => + createPrimitiveNode("image", attrs, slots), + NO_FALLTHROUGH, +); function resolveActive(active: unknown): boolean { const resolved = valueOf(active); diff --git a/framework/src/devtools.ts b/framework/src/devtools.ts index 84f483ca..40195b4a 100644 --- a/framework/src/devtools.ts +++ b/framework/src/devtools.ts @@ -159,9 +159,9 @@ export function initDevtools(ops: HostOps): void { /** Wrap the composed frame handler (render()'s input+hooks+sweep closure). */ export function wrapFrameHandler( - h: (buttons: number, analog: number, touches?: readonly number[]) => void, -): (buttons: number, analog?: number, touches?: readonly number[]) => void { - return (buttons: number, analogArg?: number, touchArg?: readonly number[]) => { + h: (buttons: number, analog: number, touches?: readonly number[], touchEvents?: readonly number[]) => void, +): (buttons: number, analog?: number, touches?: readonly number[], touchEvents?: readonly number[]) => void { + return (buttons: number, analogArg?: number, touchArg?: readonly number[], touchEventArg?: readonly number[]) => { state.hostCalls++; if (state.transport) { pollTransport(); @@ -170,6 +170,7 @@ export function wrapFrameHandler( let mask = buttons; let analog = analogArg === undefined ? ANALOG_CENTER : analogArg & 0xffff; let touch = touchArg; + let touchEvents = touchEventArg; if (state.replayMasks) { if (state.replayAt < state.replayMasks.length) { mask = state.replayMasks[state.replayAt]; @@ -177,6 +178,7 @@ export function wrapFrameHandler( // A replay that predates touch input must not leak live hardware state // into the deterministic tape. touch = undefined; + touchEvents = undefined; state.replayAt++; } else { state.replayMasks = null; // tape exhausted: back to live input @@ -192,7 +194,7 @@ export function wrapFrameHandler( recordMask(mask, analog); state.frame++; try { - h(mask, analog, touch); + h(mask, analog, touch, touchEvents); } catch (e) { send({ t: "error", diff --git a/framework/src/index-vue-vapor.ts b/framework/src/index-vue-vapor.ts index 81a5590f..167d77e6 100644 --- a/framework/src/index-vue-vapor.ts +++ b/framework/src/index-vue-vapor.ts @@ -30,6 +30,7 @@ import { registerStyles, resolveStyle } from "./styles.ts"; import { handleFrame, setInputRoot } from "./input.ts"; import { __setAnalog, resetFrameHooks, runFrameHooks } from "./frame-vue-vapor.ts"; import { __resetTouches, __setTouches } from "./touch.ts"; +import { handleTouchSamples, resetTouchEvents, setTouchHitRootProvider } from "./touch-events.ts"; import { __advanceClock, resetClock } from "./clock.ts"; import { __drainEffects, resetEffects } from "./effects.ts"; import { entries as pakEntries, get as pakGet, hasPack, loadPack } from "./pak.ts"; @@ -196,15 +197,19 @@ export function render(code: VaporRenderRoot, opts: RenderOptions = {}): () => v overlayLayer = overlayRoot; setInputRoot(appRoot); + setTouchHitRootProvider(() => rootMirror); // touch events see app + overlay resetFrameHooks(); resetClock(); // clock policy + effect shell (docs/DETERMINISM.md), same as Solid resetEffects(); initDevtools(host.ops); // DevTools shim (docs/DEVTOOLS.md), same as the Solid path. installFrameHandler( - wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[]) => { + wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[], touchEvents?: readonly number[]) => { __advanceClock(); __setAnalog(analog); __setTouches(touches); + // ESP32 QuickJS delivers samples via __tev (its frame wire is 1-arg); + // every other host passes them as the 4th frame argument. + handleTouchSamples(touchEvents ?? (globalThis as { __tev?: readonly number[] }).__tev); __drainEffects(); runFrameHooks(buttons); handleFrame(buttons); @@ -217,8 +222,10 @@ export function render(code: VaporRenderRoot, opts: RenderOptions = {}): () => v return () => { removeResizeViewportHook(); __resetTouches(); + resetTouchEvents(); // drop capture table (native nodes die with the tree) dispose(); setInputRoot(null); + setTouchHitRootProvider(null); setOverlayRoot(null); appLayer = null; overlayLayer = null; diff --git a/framework/src/index.ts b/framework/src/index.ts index fe86fb5b..86ac4d80 100644 --- a/framework/src/index.ts +++ b/framework/src/index.ts @@ -43,6 +43,7 @@ import { registerStyles, resolveStyle } from "./styles.ts"; import { handleFrame, setHitRoot, setInputRoot } from "./input.ts"; import { __setAnalog, resetFrameHooks, runFrameHooks } from "./frame.ts"; import { __resetTouches, __setTouches } from "./touch.ts"; +import { handleTouchSamples, resetTouchEvents, setTouchHitRootProvider } from "./touch-events.ts"; import { __advanceClock, resetClock } from "./clock.ts"; import { __drainEffects, resetEffects } from "./effects.ts"; import { entries as pakEntries, get as pakGet, hasPack, loadPack } from "./pak.ts"; @@ -249,16 +250,18 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi setInputRoot(appRoot); setHitRoot(rootMirror); // hit tests see the overlay layer too + setTouchHitRootProvider(() => rootMirror); // touch events resolve against the same tree resetFrameHooks(); resetClock(); // latches the host's __simHz clock policy (docs/DETERMINISM.md) resetEffects(); initDevtools(host.ops); // DevTools shim (docs/DEVTOOLS.md): flight recorder + // debug channel; one branch per frame when no transport is connected. installFrameHandler( - wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[]) => { + wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[], touchEvents?: readonly number[]) => { __advanceClock(); // virtual frame++, fire due after() timers __setAnalog(analog); // latch the nub before any app code reads it __setTouches(touches); // latch logical front-panel contacts for this frame + handleTouchSamples(touchEvents); // browser-aligned dispatch (W3C subset) __drainEffects(); // frame-boundary deliveries enter the world first runFrameHooks(buttons); // app lifecycle callbacks: onFrame/onButtonPress/etc. handleFrame(buttons); // edge-detect, focus nav, onPress (runs effects) @@ -271,9 +274,11 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi return () => { removeResizeViewportHook(); __resetTouches(); + resetTouchEvents(); // drop capture table (native nodes die with the tree) dispose(); // tears down reactivity only — universal keeps the nodes setInputRoot(null); // drops focus state (native focus dies with the nodes) setHitRoot(null); + setTouchHitRootProvider(null); setOverlayRoot(null); appLayer = null; overlayLayer = null; diff --git a/framework/src/input-api.ts b/framework/src/input-api.ts index 8ac4ae8c..2bd9103b 100644 --- a/framework/src/input-api.ts +++ b/framework/src/input-api.ts @@ -2,6 +2,7 @@ export { BTN } from "../../contracts/spec/spec.ts"; export { touches, type TouchContact } from "./touch.ts"; +export type { PocketTouch, PocketTouchEvent, TouchEventType } from "./touch-events.ts"; export { cursorX, cursorY, diff --git a/framework/src/native-tree.ts b/framework/src/native-tree.ts index 175cce74..093bf802 100644 --- a/framework/src/native-tree.ts +++ b/framework/src/native-tree.ts @@ -4,6 +4,7 @@ import { NODE_TYPE, PROP, ROOT_ID, STYLE_ID_NONE, type PropName } from "../../contracts/spec/spec.ts"; import { encodePropValue, getHost, getOps } from "./host.ts"; import { __notifyTreeMutation, notifyDetached, registerFocusable, registerPress } from "./input.ts"; +import { registerTouchHandler, touchPhaseForProp, type TouchHandler } from "./touch-events.ts"; export interface NodeMirror { /** Native generation-tagged node id. */ @@ -27,8 +28,7 @@ export interface NodeMirror { /** CIRCLE handler while focused (input.ts). */ onPress?: (() => void) | undefined; /** DevTools semantic name (`debugName` prop / wrapper). */ - debugName?: string; -} + debugName?: string;} // DevTools (docs/DEVTOOLS.md): one nullable hook, pinged on any structural or // content mutation of the mirror tree so the shim can re-snapshot lazily. @@ -74,6 +74,14 @@ const NATIVE_ATTRIBUTE_NAMES = new Set([ "src", "onPress", "on:press", + "onTouchstart", + "on:touchstart", + "onTouchmove", + "on:touchmove", + "onTouchend", + "on:touchend", + "onTouchcancel", + "on:touchcancel", "focusable", "debugName", "ref", @@ -601,6 +609,11 @@ export function setProp(node: NodeMirror, name: string, value: T, prev?: T): if (value == null) delete domAttrs(node)[name]; else domAttrs(node)[name] = value; } + const touchPhase = touchPhaseForProp(name); + if (touchPhase !== -1) { + registerTouchHandler(node, touchPhase, value as TouchHandler | undefined); + return value; + } switch (name) { case "class": setClass(node, value); diff --git a/framework/src/prelude.ts b/framework/src/prelude.ts index 315aab36..c930d63b 100644 --- a/framework/src/prelude.ts +++ b/framework/src/prelude.ts @@ -24,11 +24,16 @@ if (typeof (globalThis as { clearTimeout?: unknown }).clearTimeout !== "function } if (typeof (globalThis as { console?: unknown }).console !== "object") { - (globalThis as unknown as { console?: Record void> }).console = { - log() {}, - warn() {}, - error() {}, - }; + (globalThis as unknown as { console?: Record void> }).console = {}; +} +{ + // QuickJS's js_std_add_helpers ships console.log only; Vue's error + // handler calls console.error unconditionally. Fill any missing level, + // defaulting to the host's log (or a no-op) so console.* never throws. + const c = (globalThis as unknown as { console: Record void) | undefined> }).console; + for (const level of ["log", "warn", "error"] as const) { + if (typeof c[level] !== "function") c[level] = c.log ?? (() => {}); + } } installVueVaporDom(); diff --git a/framework/src/primitives.ts b/framework/src/primitives.ts index 7a064b5d..12601f1d 100644 --- a/framework/src/primitives.ts +++ b/framework/src/primitives.ts @@ -8,6 +8,7 @@ import type { JSX as SolidJSX } from "solid-js"; import { createElement, spread } from "./renderer.ts"; import type { NodeMirror } from "./renderer.ts"; +import type { TouchHandler } from "./touch-events.ts"; type StyleObject = Record; type RefProp = @@ -16,7 +17,17 @@ type RefProp = | NodeMirror | undefined; -export interface ViewProps { +/** W3C-subset touch handlers shared by every primitive (framework/src/touch-events.ts). + * JSX spells them onTouchstart etc.; Vue templates bind @touchstart. Both land in + * native-tree's touch registry via setProperty. */ +export interface TouchHandlers { + onTouchstart?: TouchHandler; + onTouchmove?: TouchHandler; + onTouchend?: TouchHandler; + onTouchcancel?: TouchHandler; +} + +export interface ViewProps extends TouchHandlers { class?: string; style?: StyleObject; onPress?: () => void; @@ -28,7 +39,7 @@ export interface ViewProps { children?: SolidJSX.Element; } -export interface TextProps { +export interface TextProps extends TouchHandlers { class?: string; style?: StyleObject; /** DevTools semantic name shown in the component tree (docs/DEVTOOLS.md). */ @@ -38,7 +49,7 @@ export interface TextProps { children?: SolidJSX.Element; } -export interface ImageProps { +export interface ImageProps extends TouchHandlers { class?: string; src?: string; style?: StyleObject; @@ -54,7 +65,7 @@ function callRef(ref: RefProp, node: NodeMirror): void { else if ("current" in ref) ref.current = node; } -export interface SpriteProps { +export interface SpriteProps extends TouchHandlers { class?: string; /** DevTools semantic name shown in the component tree (docs/DEVTOOLS.md). */ debugName?: string; diff --git a/framework/src/touch-events.ts b/framework/src/touch-events.ts new file mode 100644 index 00000000..2df53af2 --- /dev/null +++ b/framework/src/touch-events.ts @@ -0,0 +1,370 @@ +// Touch events: browser-aligned dispatch over the native mirror tree. +// +// Pipeline (see docs/prd-touch-events.md in the ESP32 port repo): +// hardware interrupt -> host sample task -> ring buffer -> ONE packed u32 +// event stream per frame -> this module -> W3C Touch Events subset. +// +// Key properties, by design: +// - SAMPLE-LEVEL dispatch: every drained sample is its own event, so a +// sub-frame down+up can never be lost (the old per-frame snapshot model +// in touch.ts lost exactly these). +// - ONE hit test per gesture, at touchstart (spec op 27). move/end walk +// the implicit-capture table instead — active dragging costs no FFI. +// - W3C implicit target capture (§5.2): the node hit at touchstart owns +// the whole sequence, even when the contact slides off it. +// - Bubble phase only, with stopPropagation. No capture phase, no passive +// listeners — both are additive later; see the PRD grill answers. +// - Zero touch = zero cost: no drained events -> no FFI, no allocation. +// - Deterministic: events carry hardware sample times as RELATIVE dticks; +// timeStamp is reconstructed from the virtual clock, so input tapes +// replay byte-identically at every simulationHz. + +import { virtualNow } from "./clock.ts"; +import { getOps } from "./host.ts"; +import type { NodeMirror } from "./native-tree.ts"; + +// ---- wire format (host -> framework), one u32 per event ---------------------- +// +// bits [1:0] phase: 0=start, 1=move, 2=end, 3=cancel +// bits [11:2] x (logical px, 10-bit, 0..1023) +// bits [21:12] y (logical px, 10-bit, 0..1023) +// bits [27:22] dticks: ms since this contact's previous event, clamped at 63 +// bits [31:28] contact identifier (0..15) +// +// Single-touch controllers (ESP32-S3's CST9217) always report identifier 0, +// which makes per-contact dticks identical to a shared timeline — fully +// backward compatible with a host that predates the id field. + +export const enum TouchPhase { + Start = 0, + Move = 1, + End = 2, + Cancel = 3, +} + +export interface TouchSample { + phase: TouchPhase; + x: number; + y: number; + /** Milliseconds since this contact's previous sample (clamped to 63). */ + dticks: number; + /** Contact identifier (0..15); 0 on single-touch hardware. */ + id: number; +} + +export function decodeTouchSample(packed: number): TouchSample { + return { + phase: (packed & 0x3) as TouchPhase, + x: (packed >>> 2) & 0x3ff, + y: (packed >>> 12) & 0x3ff, + dticks: (packed >>> 22) & 0x3f, + id: (packed >>> 28) & 0xf, + }; +} + +/** Test/capture helper matching the wire format. */ +export function __packTouchSample( + phase: TouchPhase, + x: number, + y: number, + dticks = 0, + id = 0, +): number { + return ( + (((id & 0xf) << 28) | + ((Math.min(dticks, 63) & 0x3f) << 22) | + ((y & 0x3ff) << 12) | + ((x & 0x3ff) << 2) | + (phase & 0x3)) >>> + 0 + ); +} + +// ---- event object (W3C Touch Events subset) ----------------------------------- + +export interface PocketTouch { + /** Stable for the contact's lifetime; 0 on single-touch hardware. */ + readonly identifier: number; + /** Logical viewport coordinates. */ + readonly clientX: number; + readonly clientY: number; + /** No windowing on PocketJS targets: aliased to clientX/Y. */ + readonly screenX: number; + readonly screenY: number; + /** Implicit capture target (the node hit at touchstart). */ + readonly target: NodeMirror; +} + +export type TouchEventType = "touchstart" | "touchmove" | "touchend" | "touchcancel"; +export type TouchHandler = (ev: PocketTouchEvent) => void; + +export interface PocketTouchEvent { + readonly type: TouchEventType; + /** Contacts still active at dispatch time (W3C §5.3). */ + readonly touches: readonly PocketTouch[]; + /** Active contacts whose target equals currentTarget, per bubble step. */ + readonly targetTouches: readonly PocketTouch[]; + /** Contacts that changed in this event. */ + readonly changedTouches: readonly PocketTouch[]; + /** ms of virtual time at the SAMPLE moment (hardware capture time). */ + readonly timeStamp: number; + /** The implicit-capture target; constant through the bubble walk. */ + readonly target: NodeMirror; + /** The node currently being invoked; walks target -> ... -> root. */ + readonly currentTarget: NodeMirror | null; + readonly altKey: false; + readonly ctrlKey: false; + readonly metaKey: false; + readonly shiftKey: false; + defaultPrevented: boolean; + preventDefault(): void; + stopPropagation(): void; + /** Internal: set by stopPropagation; read by the bubble loop. */ + readonly __stopped: boolean; +} + +// ---- handler registry (native-tree setProperty dispatch target) ---------------- + +const PHASE_TO_TYPE: Record = { + [TouchPhase.Start]: "touchstart", + [TouchPhase.Move]: "touchmove", + [TouchPhase.End]: "touchend", + [TouchPhase.Cancel]: "touchcancel", +}; + +const PROP_TO_PHASE: Record = { + onTouchstart: TouchPhase.Start, + "on:touchstart": TouchPhase.Start, + onTouchmove: TouchPhase.Move, + "on:touchmove": TouchPhase.Move, + onTouchend: TouchPhase.End, + "on:touchend": TouchPhase.End, + onTouchcancel: TouchPhase.Cancel, + "on:touchcancel": TouchPhase.Cancel, +}; + +/** Mirror-side handler slots, indexed by TouchPhase. */ +type HandlerSlots = (TouchHandler | undefined)[]; + +const slots = new WeakMap(); + +/** Returns the TouchPhase for a touch prop name, or -1 for non-touch props. */ +export function touchPhaseForProp(name: string): TouchPhase | -1 { + const phase = PROP_TO_PHASE[name]; + return phase === undefined ? -1 : (phase as TouchPhase); +} + +export function registerTouchHandler( + node: NodeMirror, + phase: TouchPhase, + fn: TouchHandler | undefined | null, +): void { + let s = slots.get(node); + if (!s) { + s = [undefined, undefined, undefined, undefined]; + slots.set(node, s); + } + s[phase] = fn ?? undefined; +} + +function handlerFor(node: NodeMirror, phase: TouchPhase): TouchHandler | undefined { + return slots.get(node)?.[phase]; +} + +// ---- dispatch state -------------------------------------------------------------- + +interface ActiveContact extends PocketTouch { + /** Current position (mutated by move samples; target stays fixed). */ + clientX: number; + clientY: number; + screenX: number; + screenY: number; +} + +/** Active contacts keyed by identifier: one entry per simultaneous touch + * point. Single-touch hardware only ever uses key 0. */ +const active = new Map(); + +/** The mirror subtree root for hit resolution (app + overlay layers), set + * by render() alongside setInputRoot. Null falls back to input.ts's root. */ +let hitRootProvider: (() => NodeMirror | null) | null = null; + +/** render() injects the hit root getter (index*.ts knows app+overlay). */ +export function setTouchHitRootProvider(fn: (() => NodeMirror | null) | null): void { + hitRootProvider = fn; +} + +/** Tests / unmount: drop all touch state without touching host ops. */ +export function resetTouchEvents(): void { + active.clear(); +} + +// findMirror walks the mirror tree by native id. Duplicated from input.ts's +// private helper deliberately: input.ts's copy resolves the FOCUS root, +// this one resolves the HIT root, and neither should import the other's +// wiring at module scope. +function findMirror(node: NodeMirror | null, id: number): NodeMirror | null { + if (!node || id === 0) return null; + if (node.id === id) return node; + const kids = node.children; + if (!Array.isArray(kids)) return null; + for (let i = 0; i < kids.length; i++) { + const found = findMirror(kids[i], id); + if (found) return found; + } + return null; +} + +// ---- per-sample dispatch ----------------------------------------------------------- + +function makeTouchEvent( + type: TouchEventType, + target: NodeMirror, + changed: PocketTouch[], + timeStamp: number, +): PocketTouchEvent { + let stopped = false; + const ev: PocketTouchEvent = { + type, + touches: Object.freeze([...active.values()]), + targetTouches: [], // recomputed per bubble step below + changedTouches: Object.freeze(changed), + timeStamp, + target, + currentTarget: null, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + defaultPrevented: false, + preventDefault() { + ev.defaultPrevented = true; + }, + stopPropagation() { + stopped = true; + }, + get __stopped() { + return stopped; + }, + } as PocketTouchEvent; + return ev; +} + +function dispatch(phase: TouchPhase, contact: ActiveContact, timeStamp: number): void { + const type = PHASE_TO_TYPE[phase]; + const changed: PocketTouch[] = [ + { + identifier: contact.identifier, + clientX: contact.clientX, + clientY: contact.clientY, + screenX: contact.screenX, + screenY: contact.screenY, + target: contact.target, + }, + ]; + const ev = makeTouchEvent(type, contact.target, changed, timeStamp); + + let n: NodeMirror | null = contact.target; + while (n) { + const h = handlerFor(n, phase); + if (h) { + (ev as { currentTarget: NodeMirror | null }).currentTarget = n; + (ev as { targetTouches: readonly PocketTouch[] }).targetTouches = Object.freeze( + [...active.values()].filter((c) => c.target === n), + ); + h(ev); + } + if (ev.__stopped) break; + n = n.parent; + } + (ev as { currentTarget: NodeMirror | null }).currentTarget = null; +} + +/** One host-drained frame of touch samples. Called once per frame from the + * frame handler, BEFORE the renderer sweep, with the frame's virtual end + * time. Samples are dispatched in arrival order; each sample's timeStamp + * walks backwards from frameEndMs by its own dtick. */ +export function handleTouchSamples( + packed: readonly number[] | undefined, + _frameEndMs?: number, +): void { + if (!packed || packed.length === 0) return; + const frameEndMs = _frameEndMs ?? virtualNow() * 1000; + + // Precompute absolute sample times. dticks is PER CONTACT: sample i of + // contact c happened at frameEndMs minus the sum of dticks of all later + // samples of contact c. Walk the frame backwards keeping one running + // offset per contact id, so interleaved contacts each reconstruct their + // own timeline. Single-contact streams (id always 0) reduce to a shared + // timeline — identical to the pre-id behaviour. + const times = new Array(packed.length); + const backByContact = new Map(); + for (let i = packed.length - 1; i >= 0; i--) { + const id = (packed[i] >>> 28) & 0xf; + const back = (backByContact.get(id) ?? 0) + ((packed[i] >>> 22) & 0x3f); + backByContact.set(id, back); + times[i] = frameEndMs - back; + } + + for (let i = 0; i < packed.length; i++) { + const s = decodeTouchSample(packed[i]); + const t = times[i]; + switch (s.phase) { + case TouchPhase.Start: { + if (active.has(s.id)) break; // this contact is already down: host glitch + const ops = getOps(); + const root = hitRootProvider?.() ?? null; + let target: NodeMirror | null = null; + // Hosts that pre-compute hitTest deliver it via __tevHit (ESP32: + // one Rust tree walk in C, no QuickJS bridge, and no need for a + // ui.hitTest binding at all). Otherwise use spec op 27 from JS. + const hitArr = (globalThis as { __tevHit?: ArrayLike }).__tevHit; + if (hitArr) { + target = findMirror(root, hitArr[i] | 0) || root; + } else if (ops.hitTest) { + target = findMirror(root, ops.hitTest(s.x, s.y)) || root; + } else { + target = root; + } + if (!target) break; + const contact: ActiveContact = { + identifier: s.id, + clientX: s.x, + clientY: s.y, + screenX: s.x, + screenY: s.y, + target, + }; + active.set(s.id, contact); + dispatch(TouchPhase.Start, contact, t); + break; + } + case TouchPhase.Move: { + const contact = active.get(s.id); + if (!contact) break; // move without a start on this host: ignore + // W3C: no touchmove without movement (integer logical coords). + if (contact.clientX === s.x && contact.clientY === s.y) break; + contact.clientX = s.x; + contact.clientY = s.y; + contact.screenX = s.x; + contact.screenY = s.y; + dispatch(TouchPhase.Move, contact, t); + break; + } + case TouchPhase.End: + case TouchPhase.Cancel: { + const contact = active.get(s.id); + if (!contact) break; + contact.clientX = s.x; + contact.clientY = s.y; + contact.screenX = s.x; + contact.screenY = s.y; + // W3C §5.3: at touchend, `touches` no longer contains the released + // contact, `changedTouches` does. Remove BEFORE dispatch. + active.delete(s.id); + dispatch(s.phase, contact, t); + break; + } + } + } +} diff --git a/framework/src/touch.ts b/framework/src/touch.ts index 0c253efa..68b78375 100644 --- a/framework/src/touch.ts +++ b/framework/src/touch.ts @@ -22,13 +22,7 @@ const EMPTY: readonly TouchContact[] = Object.freeze([]); let snapshot: readonly TouchContact[] = EMPTY; -/** - * Internal host-frame hook. - * - * Existing hosts pack x:9, y:9, id:8 with bit 31 clear. Native viewports - * wider than 512 use the append-only wide form: bit31=1, x:10, y:10, id:8. - * Per-contact detection keeps every PSP/Vita tape and host byte-compatible. - */ +/** Internal host-frame hook. Each u32 packs x:10, y:10, id:8. */ export function __setTouches(packed: readonly number[] | undefined): void { if (!packed || packed.length === 0) { snapshot = EMPTY; diff --git a/hosts/sim/sim.ts b/hosts/sim/sim.ts index 5a1a716c..9b67707b 100644 --- a/hosts/sim/sim.ts +++ b/hosts/sim/sim.ts @@ -209,7 +209,7 @@ export async function bootWorld( return { frame, tick: wasm.tick, - render: () => wasm.renderScaled(renderScale), + render: () => renderScale === 1 ? wasm.render() : wasm.renderScaled(renderScale), ticksPerFrame: TICKS_PER_SECOND / hz, hz, effects, diff --git a/hosts/vita/src/input/touch.rs b/hosts/vita/src/input/touch.rs index 06d47220..4ec3c93e 100644 --- a/hosts/vita/src/input/touch.rs +++ b/hosts/vita/src/input/touch.rs @@ -48,9 +48,9 @@ impl TouchPanel { } pub fn pack(self, report: SceTouchReport, logical_w: i32, logical_h: i32) -> u32 { - let x = Self::logical_axis(report.x, self.min_x, self.max_x, logical_w) & 0x1ff; - let y = Self::logical_axis(report.y, self.min_y, self.max_y, logical_h) & 0x1ff; - ((report.id as u32) << 18) | (y << 9) | x + let x = Self::logical_axis(report.x, self.min_x, self.max_x, logical_w) & 0x3ff; + let y = Self::logical_axis(report.y, self.min_y, self.max_y, logical_h) & 0x3ff; + ((report.id as u32) << 20) | (y << 10) | x } } @@ -88,9 +88,9 @@ impl TouchSnapshot { let max_x = (crate::graphics::LOGICAL_W - 1).max(0) as u32; let max_y = (crate::graphics::LOGICAL_H - 1).max(0) as u32; for (index, (id, x, y)) in contacts.iter().take(snapshot.len).enumerate() { - let x = (*x as u32).min(max_x) & 0x1ff; - let y = (*y as u32).min(max_y) & 0x1ff; - snapshot.packed[index] = ((*id as u32) << 18) | (y << 9) | x; + let x = (*x as u32).min(max_x) & 0x3ff; + let y = (*y as u32).min(max_y) & 0x3ff; + snapshot.packed[index] = ((*id as u32) << 20) | (y << 10) | x; } snapshot } @@ -127,20 +127,20 @@ mod tests { let panel = TouchPanel::from_info(panel_info()); let top_left = panel.pack(report(7, 0, 0), 480, 272); let bottom_right = panel.pack(report(9, 1919, 1087), 480, 272); - assert_eq!(top_left & 0x1ff, 0); - assert_eq!((top_left >> 9) & 0x1ff, 0); - assert_eq!(top_left >> 18, 7); - assert_eq!(bottom_right & 0x1ff, 479); - assert_eq!((bottom_right >> 9) & 0x1ff, 271); - assert_eq!(bottom_right >> 18, 9); + assert_eq!(top_left & 0x3ff, 0); + assert_eq!((top_left >> 10) & 0x3ff, 0); + assert_eq!(top_left >> 20, 7); + assert_eq!(bottom_right & 0x3ff, 479); + assert_eq!((bottom_right >> 10) & 0x3ff, 271); + assert_eq!(bottom_right >> 20, 9); } #[test] fn clamps_reports_outside_the_display_area() { let panel = TouchPanel::from_info(panel_info()); let packed = panel.pack(report(1, -200, 2000), 480, 272); - assert_eq!(packed & 0x1ff, 0); - assert_eq!((packed >> 9) & 0x1ff, 271); + assert_eq!(packed & 0x3ff, 0); + assert_eq!((packed >> 10) & 0x3ff, 271); } #[test] @@ -148,7 +148,7 @@ mod tests { let snapshot = super::TouchSnapshot::from_logical(&[(4, 120, 80), (9, 900, 900)]); assert_eq!( snapshot.packed(), - &[4 << 18 | 80 << 9 | 120, 9 << 18 | 271 << 9 | 479] + &[4 << 20 | 80 << 10 | 120, 9 << 20 | 271 << 10 | 479] ); } } diff --git a/hosts/web/engine.js b/hosts/web/engine.js index 4c0559bb..a5b7af4d 100644 --- a/hosts/web/engine.js +++ b/hosts/web/engine.js @@ -102,6 +102,19 @@ let rafId = 0; let acc = 0; let last = 0; let frameCb = null; +// Browser-aligned touch: canvas pointer events are QUEUED as timestamped +// samples and drained at the frame boundary (framework/src/touch-events.ts +// wire format). This replaces the per-frame snapshot + deferred release +// workaround — sub-frame taps now dispatch both events. +let touchSamples = []; // [{phase, x, y, id, dt}] drained by safeFrame +const touchLastMs = new Map(); // pointerId -> performance.now() of that contact's previous sample +function queueTouch(phase, x, y, id = 0) { + const now = performance.now(); + const prev = touchLastMs.get(id) ?? 0; + const dt = prev > 0 ? Math.min(63, Math.max(0, Math.round(now - prev))) : 0; + touchLastMs.set(id, now); + touchSamples.push({ phase, x, y, id, dt }); +} // Virtual clock policy (docs/DETERMINISM.md): virtual frames per second. One // frame(buttons) transaction + 60/simHz core ticks per virtual frame, so // ms-based animations cover the same VIRTUAL time at every rate. ?hz=2 @@ -181,7 +194,7 @@ function devtoolsScreenshot() { shot.height = FB_H; const sctx = shot.getContext("2d"); const img = sctx.createImageData(FB_W, FB_H); - img.data.set(wasm.renderScaled(RENDER_SCALE)); + img.data.set(RENDER_SCALE === 1 ? wasm.render() : wasm.renderScaled(RENDER_SCALE)); sctx.putImageData(img, 0, 0); const frame = globalThis.__pocketDevtools ? globalThis.__pocketDevtools.frame : 0; dtSend(JSON.stringify({ t: "screenshot", frame, data: shot.toDataURL("image/png") })); @@ -211,8 +224,13 @@ async function devtoolsSeek(frame) { function safeFrame() { if (!frameCb) return; try { - // JS: one virtual-frame transaction (input, effects, sweep) - frameCb(held, packedAnalog()); + // JS: one virtual-frame transaction (input, effects, sweep). + // arg4: drained touch event stream (one packed u32 per sample). + const events = touchSamples.length > 0 + ? touchSamples.map((s) => (((s.id & 0xf) << 28) | ((s.dt & 0x3f) << 22) | ((s.y & 0x3ff) << 12) | ((s.x & 0x3ff) << 2) | (s.phase & 0x3)) >>> 0) + : undefined; + touchSamples = []; + frameCb(held, packedAnalog(), undefined, events); const ticks = 60 / simHz; for (let t = 0; t < ticks; t++) wasm.tick(); // core catch-up: 1/60 s each } catch (e) { @@ -319,7 +337,47 @@ export async function mount(theCanvas, opts = {}) { window.addEventListener("blur", () => { held = 0; nubHeld.clear(); + // Held contacts whose window loses focus are cancels, not ends. + for (const [pid, c] of touchContacts) queueTouch(3, c.x, c.y, pid); + touchContacts.clear(); + }); + // Canvas pointer → touch sample stream (logical coords). Each pointerId is + // an independent contact (multi-touch); the framework keys its capture + // table by the same id. + const toLogical = (e) => { + const rect = canvas.getBoundingClientRect(); + return { + x: Math.round((e.clientX - rect.left) * (LOGICAL_W / rect.width)), + y: Math.round((e.clientY - rect.top) * (LOGICAL_H / rect.height)), + }; + }; + const touchContacts = new Map(); // pointerId -> {x, y} of the last queued sample + canvas.addEventListener("pointerdown", (e) => { + e.preventDefault(); + const { x, y } = toLogical(e); + touchContacts.set(e.pointerId, { x, y }); + queueTouch(0, x, y, e.pointerId); // start + globalThis.__pocketTouch = { x, y, phase: "down" }; + }); + canvas.addEventListener("pointermove", (e) => { + const c = touchContacts.get(e.pointerId); + if (!c) return; + const { x, y } = toLogical(e); + if (x === c.x && y === c.y) return; // W3C: no move without movement + c.x = x; c.y = y; + queueTouch(1, x, y, e.pointerId); // move + globalThis.__pocketTouch = { x, y, phase: "move" }; }); + const markRelease = (e) => { + const c = touchContacts.get(e.pointerId); + if (!c) return; + touchContacts.delete(e.pointerId); + queueTouch(2, c.x, c.y, e.pointerId); // end at the last known position + if (globalThis.__pocketTouch) globalThis.__pocketTouch.phase = "up"; + }; + canvas.addEventListener("pointerup", markRelease); + canvas.addEventListener("pointercancel", markRelease); + canvas.addEventListener("lostpointercapture", markRelease); const hzParam = Number(query.get("hz")); if (VALID_HZ.includes(hzParam)) simHz = hzParam; const res = await fetch("pocketjs.wasm"); diff --git a/tests/launcher-sim.test.ts b/tests/launcher-sim.test.ts index 2aabab1e..c0838fd7 100644 --- a/tests/launcher-sim.test.ts +++ b/tests/launcher-sim.test.ts @@ -85,7 +85,7 @@ describe("launcher registry admission", () => { test("Vita admits the same current demo set through its own target profile", () => { expect(vitaRegistry.apps).toEqual(registry.apps); - expect(vitaRegistry.apps).toHaveLength(17); + expect(vitaRegistry.apps).toHaveLength(18); }); test("committed registry.generated.ts is fresh (re-run tools/launcher.ts scan)", async () => { diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index 7eec6a06..9f13741f 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -351,6 +351,7 @@ describe("semantic resolution", () => { notifications: [true, true, false], settings: [true, true, false], stats: [true, true, false], + "touch-lab": [true, true, false], // fixed-viewport touch demo, same shape as zoomlab "vue-sfc-lab": [true, true, false], zoomlab: [true, true, false], }; diff --git a/tests/touch-events.test.ts b/tests/touch-events.test.ts new file mode 100644 index 00000000..a5182c50 --- /dev/null +++ b/tests/touch-events.test.ts @@ -0,0 +1,527 @@ +// Touch events contract tests — drive framework/src/touch-events.ts against a +// mock HostOps with a scriptable hitTest and hand-built mirror trees, no +// reconciler in the loop (same harness as cursor.test.ts). +// +// Semantic oracle: W3C Touch Events (test names cite the section). The one +// deliberate deviation is bubble-only dispatch (no capture phase, no passive +// listeners) — see docs/prd-touch-events.md grill answers in the ESP32 repo. +// +// Run: bun test --conditions=browser tests/touch-events.test.ts + +import { beforeEach, describe, expect, test } from "bun:test"; + +import { installHost, type Host, type HostOps } from "../framework/src/host.ts"; +import type { NodeMirror } from "../framework/src/native-tree.ts"; +import { + __packTouchSample, + handleTouchSamples, + registerTouchHandler, + resetTouchEvents, + setTouchHitRootProvider, + TouchPhase, + type PocketTouchEvent, +} from "../framework/src/touch-events.ts"; +import { NODE_TYPE, ROOT_ID } from "../contracts/spec/spec.ts"; + +type Call = [string, ...unknown[]]; + +interface TouchMockHost extends Host { + calls: Call[]; + of(...names: string[]): Call[]; + hitResult: number; +} + +function makeTouchHost(): TouchMockHost { + const calls: Call[] = []; + const rec = + (name: string) => + (...args: unknown[]) => { + calls.push([name, ...args]); + }; + const self: TouchMockHost = { + kind: "injected", + target: "test", + strict: true, + calls, + hitResult: 0, + of(...names: string[]) { + return calls.filter((c) => names.includes(c[0] as string)); + }, + ops: {} as HostOps, + }; + self.ops = { + createNode: () => 0, + destroyNode: rec("destroyNode"), + insertBefore: rec("insertBefore"), + removeChild: rec("removeChild"), + setStyle: rec("setStyle"), + setProp: rec("setProp"), + setText: rec("setText"), + replaceText: rec("replaceText"), + uploadTexture: () => 1, + setImage: rec("setImage"), + setSprite: rec("setSprite"), + animate: () => 1, + cancelAnim: rec("cancelAnim"), + setFocus: rec("setFocus"), + measureText: () => 0, + hitTest: (x: number, y: number) => { + calls.push(["hitTest", x, y]); + return self.hitResult; + }, + } as unknown as HostOps; + return self; +} + +let host: TouchMockHost; + +function mirror(id: number, parent: NodeMirror | null = null): NodeMirror { + const node: NodeMirror = { id, type: NODE_TYPE.view, parent, children: [] }; + if (parent) parent.children.push(node); + return node; +} + +let root: NodeMirror; +let child: NodeMirror; +let grandchild: NodeMirror; + +beforeEach(() => { + host = makeTouchHost(); + installHost(host); + resetTouchEvents(); + root = mirror(ROOT_ID); + child = mirror(0x11, root); + grandchild = mirror(0x22, child); + setTouchHitRootProvider(() => root); +}); + +const FRAME_MS = 1000 / 60; + +describe("wire format", () => { + test("pack/decode round-trips phase, coords, dticks", () => { + const p = __packTouchSample(TouchPhase.Move, 233, 117, 42); + expect((p & 0x3) as TouchPhase).toBe(TouchPhase.Move); + expect((p >>> 2) & 0x3ff).toBe(233); + expect((p >>> 12) & 0x3ff).toBe(117); + expect((p >>> 22) & 0x3f).toBe(42); + }); + + test("dticks clamp at 63", () => { + expect((__packTouchSample(TouchPhase.Move, 0, 0, 500) >>> 22) & 0x3f).toBe(63); + }); +}); + +describe("phase dispatch", () => { + test("start hits once and dispatches to the hit node (W3C §5.2 target)", () => { + host.hitResult = grandchild.id; + const seen: string[] = []; + registerTouchHandler(grandchild, TouchPhase.Start, () => seen.push("gc")); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS); + expect(seen).toEqual(["gc"]); + expect(host.of("hitTest")).toEqual([["hitTest", 10, 20]]); + }); + + test("sub-frame down+up dispatches BOTH events in order (regression: the old snapshot model lost these)", () => { + host.hitResult = child.id; + const seen: string[] = []; + registerTouchHandler(child, TouchPhase.Start, () => seen.push("start")); + registerTouchHandler(child, TouchPhase.End, () => seen.push("end")); + handleTouchSamples( + [ + __packTouchSample(TouchPhase.Start, 10, 20, 8), + __packTouchSample(TouchPhase.End, 10, 20, 0), + ], + FRAME_MS, + ); + expect(seen).toEqual(["start", "end"]); + }); + + test("no touchmove without movement (integer logical coords)", () => { + host.hitResult = child.id; + const seen: string[] = []; + registerTouchHandler(child, TouchPhase.Move, () => seen.push("move")); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS); + handleTouchSamples([__packTouchSample(TouchPhase.Move, 10, 20)], 2 * FRAME_MS); + expect(seen).toEqual([]); + handleTouchSamples([__packTouchSample(TouchPhase.Move, 11, 20)], 3 * FRAME_MS); + expect(seen).toEqual(["move"]); + }); + + test("move/end without a tracked start are ignored (host glitch safety)", () => { + const seen: string[] = []; + registerTouchHandler(root, TouchPhase.Move, () => seen.push("move")); + registerTouchHandler(root, TouchPhase.End, () => seen.push("end")); + handleTouchSamples([__packTouchSample(TouchPhase.Move, 5, 5)], FRAME_MS); + handleTouchSamples([__packTouchSample(TouchPhase.End, 5, 5)], 2 * FRAME_MS); + expect(seen).toEqual([]); + }); + + test("empty frames are free: no hitTest, no dispatch", () => { + const seen: string[] = []; + registerTouchHandler(root, TouchPhase.Start, () => seen.push("start")); + handleTouchSamples(undefined, FRAME_MS); + handleTouchSamples([], 2 * FRAME_MS); + expect(seen).toEqual([]); + expect(host.of("hitTest")).toEqual([]); + }); +}); + +describe("implicit capture (W3C §5.2)", () => { + test("move/end target stays the down node after sliding onto a sibling", () => { + const sibling = mirror(0x33, root); + host.hitResult = child.id; + const targets: number[] = []; + registerTouchHandler(child, TouchPhase.Move, (ev) => targets.push((ev.target as NodeMirror).id)); + registerTouchHandler(child, TouchPhase.End, (ev) => targets.push((ev.target as NodeMirror).id)); + registerTouchHandler(sibling, TouchPhase.Move, () => targets.push(-1)); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS); + host.hitResult = sibling.id; // finger now over the sibling + handleTouchSamples([__packTouchSample(TouchPhase.Move, 200, 20)], 2 * FRAME_MS); + handleTouchSamples([__packTouchSample(TouchPhase.End, 200, 20)], 3 * FRAME_MS); + expect(targets).toEqual([child.id, child.id]); + // and no hitTest after the start: the capture table answers move/end + expect(host.of("hitTest")).toHaveLength(1); + }); + + test("cancel delivers to the captured target", () => { + host.hitResult = grandchild.id; + const seen: string[] = []; + registerTouchHandler(grandchild, TouchPhase.Cancel, () => seen.push("cancel")); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS); + handleTouchSamples([__packTouchSample(TouchPhase.Cancel, 10, 20)], 2 * FRAME_MS); + expect(seen).toEqual(["cancel"]); + }); +}); + +describe("bubbling (W3C §5.6)", () => { + test("bubbles target -> parent -> root", () => { + host.hitResult = grandchild.id; + const order: string[] = []; + registerTouchHandler(root, TouchPhase.Start, () => order.push("root")); + registerTouchHandler(child, TouchPhase.Start, () => order.push("child")); + registerTouchHandler(grandchild, TouchPhase.Start, () => order.push("gc")); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 1, 1)], FRAME_MS); + expect(order).toEqual(["gc", "child", "root"]); + }); + + test("stopPropagation truncates the walk", () => { + host.hitResult = grandchild.id; + const order: string[] = []; + registerTouchHandler(root, TouchPhase.Start, () => order.push("root")); + registerTouchHandler(child, TouchPhase.Start, (ev) => { + order.push("child"); + ev.stopPropagation(); + }); + registerTouchHandler(grandchild, TouchPhase.Start, () => order.push("gc")); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 1, 1)], FRAME_MS); + expect(order).toEqual(["gc", "child"]); + }); + + test("currentTarget walks, target stays fixed", () => { + host.hitResult = grandchild.id; + const seen: Array<[number, number]> = []; + const log = (ev: PocketTouchEvent) => + seen.push([(ev.target as NodeMirror).id, (ev.currentTarget as NodeMirror).id]); + registerTouchHandler(child, TouchPhase.Start, log); + registerTouchHandler(root, TouchPhase.Start, log); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 1, 1)], FRAME_MS); + expect(seen).toEqual([ + [grandchild.id, child.id], + [grandchild.id, root.id], + ]); + }); +}); + +describe("event lists (W3C §5.3)", () => { + test("touchstart: touches and changedTouches contain the new contact", () => { + host.hitResult = child.id; + let ev0: PocketTouchEvent | null = null; + registerTouchHandler(child, TouchPhase.Start, (ev) => (ev0 = ev)); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS); + expect(ev0!.touches).toHaveLength(1); + expect(ev0!.changedTouches).toHaveLength(1); + expect(ev0!.changedTouches[0].clientX).toBe(10); + expect(ev0!.changedTouches[0].clientY).toBe(20); + expect(ev0!.targetTouches).toHaveLength(1); // currentTarget === target here + expect(ev0!.touches[0].target).toBe(child); + }); + + test("touchend: touches is empty, changedTouches has the released contact", () => { + host.hitResult = child.id; + let ev0: PocketTouchEvent | null = null; + registerTouchHandler(child, TouchPhase.End, (ev) => (ev0 = ev)); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS); + handleTouchSamples([__packTouchSample(TouchPhase.End, 30, 40)], 2 * FRAME_MS); + expect(ev0!.touches).toHaveLength(0); + expect(ev0!.changedTouches).toHaveLength(1); + expect(ev0!.changedTouches[0].clientX).toBe(30); + expect(ev0!.changedTouches[0].clientY).toBe(40); + }); +}); + +describe("timeStamps (sample-time, tape-deterministic)", () => { + test("timeStamp walks backwards from frame end by dticks", () => { + host.hitResult = child.id; + const stamps: number[] = []; + registerTouchHandler(child, TouchPhase.Start, (ev) => stamps.push(ev.timeStamp)); + registerTouchHandler(child, TouchPhase.Move, (ev) => stamps.push(ev.timeStamp)); + registerTouchHandler(child, TouchPhase.End, (ev) => stamps.push(ev.timeStamp)); + // start at t-16ms, move at t-8ms, end at t: dticks are 8, 8, 0 reading + // FORWARD, so the packed stream carries [start:8, move:8, end:0]. + handleTouchSamples( + [ + __packTouchSample(TouchPhase.Start, 1, 1, 8), + __packTouchSample(TouchPhase.Move, 2, 1, 8), + __packTouchSample(TouchPhase.End, 2, 1, 0), + ], + 1000, + ); + expect(stamps).toEqual([984, 992, 1000]); + }); +}); + +describe("handler registration via prop names", () => { + test("all four phases register and fire under both prop spellings", async () => { + host.hitResult = child.id; + const seen: string[] = []; + // Exercise the native-tree prop path, not just the internal registry. + const { setProp } = await import("../framework/src/native-tree.ts"); + setProp(child, "onTouchstart", () => seen.push("start")); + setProp(child, "on:touchmove", () => seen.push("move")); + setProp(child, "onTouchend", () => seen.push("end")); + setProp(child, "on:touchcancel", () => seen.push("cancel")); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 1, 1)], FRAME_MS); + handleTouchSamples([__packTouchSample(TouchPhase.Move, 2, 1)], 2 * FRAME_MS); + handleTouchSamples([__packTouchSample(TouchPhase.End, 2, 1)], 3 * FRAME_MS); + // cancel after end: contact already released, nothing fires + handleTouchSamples([__packTouchSample(TouchPhase.Cancel, 2, 1)], 4 * FRAME_MS); + expect(seen).toEqual(["start", "move", "end"]); + }); +}); + +describe("preventDefault", () => { + test("defaultPrevented round-trips within the event", () => { + host.hitResult = child.id; + let prevented = false; + registerTouchHandler(child, TouchPhase.Start, (ev) => { + ev.preventDefault(); + prevented = ev.defaultPrevented; + }); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 1, 1)], FRAME_MS); + expect(prevented).toBe(true); + }); +}); + +// ---- W3C canonical cases ----------------------------------------------------- +// These codify the semantics a browser developer expects. They exist because +// the demo app exposed two real bugs the happy-path tests above missed: +// 1. a global listener on the root saw "blank" before the element listener +// reported — bubble ORDER was under-tested; +// 2. touching bare background (no element) never reached a root listener — +// root dispatch was under-tested. + +describe("W3C canonical bubbling order", () => { + test("target first, then ancestors up to root (target phase -> bubble phase)", () => { + host.hitResult = grandchild.id; + const order: string[] = []; + registerTouchHandler(root, TouchPhase.Start, () => order.push("root")); + registerTouchHandler(child, TouchPhase.Start, () => order.push("child")); + registerTouchHandler(grandchild, TouchPhase.Start, () => order.push("grandchild")); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS); + // W3C §5.6: the event target's own listeners run FIRST (target phase), + // then bubble ancestors from innermost to outermost. + expect(order).toEqual(["grandchild", "child", "root"]); + }); + + test("currentTarget tracks the bubbling node while target stays fixed", () => { + host.hitResult = grandchild.id; + const seen: [number, number][] = []; // [currentTarget.id, target.id] + const log = (ev: PocketTouchEvent) => + seen.push([(ev.currentTarget as NodeMirror).id, (ev.target as NodeMirror).id]); + registerTouchHandler(root, TouchPhase.Start, log); + registerTouchHandler(child, TouchPhase.Start, log); + registerTouchHandler(grandchild, TouchPhase.Start, log); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS); + expect(seen).toEqual([ + [grandchild.id, grandchild.id], + [child.id, grandchild.id], + [root.id, grandchild.id], + ]); + }); + + test("root listener observes touches that hit NO element (background)", () => { + // The demo bug: tapping bare background never updated a root-level + // listener. W3C: hit tests that fall through to the document still + // dispatch, with the document (here: root) as target. + host.hitResult = 0; // no element claims the point + const seen: string[] = []; + registerTouchHandler(root, TouchPhase.Start, () => seen.push("start")); + registerTouchHandler(root, TouchPhase.End, () => seen.push("end")); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 500, 150)], FRAME_MS); + handleTouchSamples([__packTouchSample(TouchPhase.End, 500, 150)], 2 * FRAME_MS); + expect(seen).toEqual(["start", "end"]); + }); + + test("root-as-target bubbles nowhere above root (no crash, single dispatch)", () => { + host.hitResult = 0; + const seen: string[] = []; + registerTouchHandler(root, TouchPhase.Start, (ev) => { + seen.push("start"); + expect(ev.target as NodeMirror | null).toBe(ev.currentTarget); + }); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 500, 150)], FRAME_MS); + expect(seen).toEqual(["start"]); + }); + + test("stopPropagation at the target prevents ALL ancestor listeners", () => { + host.hitResult = grandchild.id; + const order: string[] = []; + registerTouchHandler(root, TouchPhase.Start, () => order.push("root")); + registerTouchHandler(child, TouchPhase.Start, () => order.push("child")); + registerTouchHandler(grandchild, TouchPhase.Start, (ev) => { + order.push("grandchild"); + ev.stopPropagation(); + }); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS); + expect(order).toEqual(["grandchild"]); + }); + + test("targetTouches lists only contacts whose target is the currentTarget (W3C §5.3)", () => { + host.hitResult = child.id; + let atChild: number | undefined; + let atRoot: number | undefined; + registerTouchHandler(child, TouchPhase.Start, (ev) => { + atChild = ev.targetTouches.length; + }); + registerTouchHandler(root, TouchPhase.Start, (ev) => { + atRoot = ev.targetTouches.length; + }); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20)], FRAME_MS); + expect(atChild).toBe(1); // the contact targets child + expect(atRoot).toBe(0); // not root, even though root sees the bubble + }); +}); + +// ---- multi-touch (contact identifier, W3C §5.1) ------------------------------- +// Two simultaneous contacts are independent: each captures its own target, +// interleaved moves do not disturb each other, and the event lists group by +// contact. Driven by hand-packed u32 streams carrying distinct id fields. + +describe("multi-touch (contact identifiers)", () => { + test("wire: id round-trips through pack/decode", () => { + const p = __packTouchSample(TouchPhase.Start, 10, 20, 5, 3); + expect((p >>> 28) & 0xf).toBe(3); + const s = __packTouchSample(TouchPhase.Move, 0, 0, 0, 15); + expect((s >>> 28) & 0xf).toBe(15); + }); + + test("two contacts capture different targets independently (W3C §5.2)", () => { + const sibling = mirror(0x33, root); + const targets: Record = { 0: [], 1: [] }; + registerTouchHandler(child, TouchPhase.Move, (ev) => + targets[ev.changedTouches[0].identifier]!.push((ev.target as NodeMirror).id), + ); + registerTouchHandler(sibling, TouchPhase.Move, (ev) => + targets[ev.changedTouches[0].identifier]!.push((ev.target as NodeMirror).id), + ); + // Contact 0 down on child, contact 1 down on sibling. + host.hitResult = child.id; + handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20, 0, 0)], FRAME_MS); + host.hitResult = sibling.id; + handleTouchSamples([__packTouchSample(TouchPhase.Start, 200, 150, 0, 1)], 2 * FRAME_MS); + // Both move elsewhere: each still reports its OWN captured target. + handleTouchSamples( + [ + __packTouchSample(TouchPhase.Move, 300, 5, 0, 0), + __packTouchSample(TouchPhase.Move, 300, 6, 0, 1), + ], + 3 * FRAME_MS, + ); + expect(targets[0]).toEqual([child.id]); + expect(targets[1]).toEqual([sibling.id]); + }); + + test("interleaved moves keep per-contact positions and touches list (W3C §5.3)", () => { + host.hitResult = child.id; + const seen: Array<[number, number, number, number]> = []; // [id, x, y, touches.length] + registerTouchHandler(child, TouchPhase.Move, (ev) => + seen.push([ + ev.changedTouches[0].identifier, + ev.changedTouches[0].clientX, + ev.changedTouches[0].clientY, + ev.touches.length, + ]), + ); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20, 0, 0)], FRAME_MS); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 100, 120, 0, 1)], 2 * FRAME_MS); + handleTouchSamples( + [ + __packTouchSample(TouchPhase.Move, 11, 21, 0, 0), + __packTouchSample(TouchPhase.Move, 101, 121, 0, 1), + ], + 3 * FRAME_MS, + ); + // Both contacts active throughout: touches.length stays 2, positions independent. + expect(seen).toEqual([ + [0, 11, 21, 2], + [1, 101, 121, 2], + ]); + }); + + test("ending one contact leaves the other tracked; touches shrinks to 1", () => { + host.hitResult = child.id; + const seen: Array<[string, number]> = []; + registerTouchHandler(child, TouchPhase.End, (ev) => + seen.push(["end", ev.changedTouches[0].identifier]), + ); + registerTouchHandler(child, TouchPhase.Move, (ev) => + seen.push(["move", ev.touches.length]), + ); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20, 0, 0)], FRAME_MS); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 100, 120, 0, 1)], 2 * FRAME_MS); + handleTouchSamples([__packTouchSample(TouchPhase.End, 100, 120, 0, 1)], 3 * FRAME_MS); + expect(seen).toEqual([["end", 1]]); + // Contact 0 still moves, and it is now the only active touch. + handleTouchSamples([__packTouchSample(TouchPhase.Move, 12, 22, 0, 0)], 4 * FRAME_MS); + expect(seen).toEqual([ + ["end", 1], + ["move", 1], + ]); + }); + + test("per-contact dticks reconstruct independent timelines (interleaved)", () => { + host.hitResult = child.id; + const stamps: Array<[number, number]> = []; // [id, timeStamp] + const log = (ev: PocketTouchEvent) => + stamps.push([ev.changedTouches[0].identifier, ev.timeStamp]); + registerTouchHandler(child, TouchPhase.Start, log); + registerTouchHandler(child, TouchPhase.Move, log); + registerTouchHandler(child, TouchPhase.End, log); + // Frame ends at t=1000. id0: start dtick 8, then move dtick 8 → move at + // 1000-8=992, start at 992-8=984. id1 interleaves with start dtick 4 → 996. + handleTouchSamples( + [ + __packTouchSample(TouchPhase.Start, 1, 1, 8, 0), + __packTouchSample(TouchPhase.Start, 50, 50, 4, 1), + __packTouchSample(TouchPhase.Move, 2, 1, 8, 0), + ], + 1000, + ); + expect(stamps).toEqual([ + [0, 984], + [1, 996], + [0, 992], + ]); + }); + + test("a second Start for an already-down id is ignored (host glitch guard)", () => { + host.hitResult = child.id; + const seen: string[] = []; + registerTouchHandler(child, TouchPhase.Start, () => seen.push("start")); + handleTouchSamples([__packTouchSample(TouchPhase.Start, 10, 20, 0, 0)], FRAME_MS); + // Re-down id 0 elsewhere without an end: must not re-hitTest or re-fire. + handleTouchSamples([__packTouchSample(TouchPhase.Start, 400, 300, 0, 0)], 2 * FRAME_MS); + expect(seen).toEqual(["start"]); + expect(host.of("hitTest")).toEqual([["hitTest", 10, 20]]); + }); +});