From 22791f95e683fb308735c050c8a62628822630b5 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:49:59 +0800 Subject: [PATCH] feat(framework): gesture layer over the touch snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit touches() was a stateless per-frame snapshot with exactly one consumer (the OSK's contact-edge tap). This adds framework/src/gesture.ts — contact lifecycles (down/move/up/cancel), per-contact history with deterministic release velocity, and recognizers for tap, long-press, and axis-lockable pan with UIKit-style claim semantics (a list pan cancels the row's press highlight). Steady state allocates nothing: 8 pooled tracks (the wire cap), Int16Array history rings, one flag byte per recognizer×slot. The pump runs in the frame handler between effect delivery and app frame hooks, so app code always observes the frame's completed gesture output; on hosts without touch (PSP) it costs two comparisons. The gesture layer never touches focus itself — input.ts grows three exports (setActiveNode, pressNode, hitNode) so touch, cursor, and d-pad share one authority over focus:/active: variants, and pushTouchBlock() mirrors pushButtonHandlerBlock for modals (opening the OSK cancels list gestures the same frame). New @pocketjs/framework/gesture subpath registered in all three maps (package.json exports, tsconfig paths, jsx-plugin FRAMEWORKS.solid.subpaths). Tests: 19 unit cases driving __setTouches/__runGestures directly — edges, slop, claim priority, hz-scaled long-press deadlines and velocities, block/ dispose cancellation. Sim byte-exact suites (cafe, im) stay green. Co-Authored-By: Claude Fable 5 --- framework/compiler/jsx-plugin.ts | 2 + framework/src/gesture.ts | 516 +++++++++++++++++++++++++++++++ framework/src/index.ts | 4 + framework/src/input-api.ts | 3 + framework/src/input.ts | 33 +- package.json | 1 + tests/gesture.test.ts | 305 ++++++++++++++++++ tsconfig.json | 1 + 8 files changed, 864 insertions(+), 1 deletion(-) create mode 100644 framework/src/gesture.ts create mode 100644 tests/gesture.test.ts diff --git a/framework/compiler/jsx-plugin.ts b/framework/compiler/jsx-plugin.ts index 5e623b8f..f6f3d955 100644 --- a/framework/compiler/jsx-plugin.ts +++ b/framework/compiler/jsx-plugin.ts @@ -38,6 +38,7 @@ const ANIMATION_PATH = new URL("../src/animation.ts", import.meta.url).pathname; const COMPONENTS_PATH = new URL("../src/components.ts", import.meta.url).pathname; const COMPONENTS_VUE_VAPOR_PATH = new URL("../src/components-vue-vapor.ts", import.meta.url).pathname; const CONFIG_PATH = new URL("../src/config.ts", import.meta.url).pathname; +const GESTURE_PATH = new URL("../src/gesture.ts", import.meta.url).pathname; const INPUT_API_PATH = new URL("../src/input-api.ts", import.meta.url).pathname; const LAUNCHER_PATH = new URL("../src/launcher.ts", import.meta.url).pathname; const LIFECYCLE_PATH = new URL("../src/lifecycle.ts", import.meta.url).pathname; @@ -77,6 +78,7 @@ export const FRAMEWORKS: Record< animation: ANIMATION_PATH, components: COMPONENTS_PATH, config: CONFIG_PATH, + gesture: GESTURE_PATH, input: INPUT_API_PATH, launcher: LAUNCHER_PATH, lifecycle: LIFECYCLE_PATH, diff --git a/framework/src/gesture.ts b/framework/src/gesture.ts new file mode 100644 index 00000000..a0cae711 --- /dev/null +++ b/framework/src/gesture.ts @@ -0,0 +1,516 @@ +// Gesture recognition over the per-frame touch snapshot (touch.ts). +// +// touches() is a stateless snapshot; this module turns it into contact +// LIFECYCLES — down / move / up / cancel edges with per-contact history and +// release velocity — and runs registered recognizers over them: tap, +// long-press, and axis-lockable pan (whose end velocity feeds flings). +// +// Ownership model (the UIKit shape, sized for a handheld): +// - On a down edge each recognizer that matches the contact's region +// becomes an OWNER; owners observe down/move/up concurrently. +// - Priority is registration order, last-registered first (the same +// convention as the focus controller stack) — deterministic because +// mount order is deterministic. +// - Discrete gestures single-fire on the highest-priority owner: the first +// owner whose pan crosses slop CLAIMS the contact and every other owner +// is cancelled (a list pan cancels the row's press-highlight); tap and +// long-press resolve to the first owner carrying that handler. +// - Region hit-testing uses the ink-claiming hitTest op when present; a +// `rect` is the geometry fallback for hosts without op 27 AND the +// complement for ink misses inside the region (gaps between rows still +// pan the list). A non-null hit OUTSIDE the subtree never rect-matches — +// ink above the region occludes it. +// +// The gesture layer never touches focus itself. Components translate +// gestures into focus/press explicitly via setActiveNode()/pressNode() +// (input.ts), so d-pad, cursor, and touch share one authority over the +// `focus:`/`active:` native variants and a pressed look can never strand. +// +// Determinism: the pump runs once per frame from index.ts — after effect +// delivery, before app frame hooks — so app code always observes this +// frame's completed gesture output. Velocity is an integer position delta +// over k fixed-dt frames (one IEEE division; bit-identical everywhere). +// Long-press deadlines are virtual-frame counts derived from simulationHz(). +// On hosts without touch (PSP) touches() is always empty and the pump costs +// two comparisons; recognizers stay inert. +// +// Steady state allocates nothing: contact tracks are a fixed pool of 8 +// (the wire cap), position history lives in preallocated Int16Array rings, +// and per-owner recognition state is a flag byte per pool slot. + +import { onCleanup } from "solid-js"; +import { simulationHz, virtualFrame } from "./clock.ts"; +import { hitNode } from "./input.ts"; +import type { NodeMirror } from "./renderer.ts"; +import { touches } from "./touch.ts"; + +export type GesturePhase = "down" | "move" | "up" | "cancel"; + +/** A live view of one contact, valid during the frame it is delivered. */ +export interface GestureContact { + /** Stable while the contact is down; ids may be reused after release. */ + readonly id: number; + /** Current position, logical viewport px. */ + readonly x: number; + readonly y: number; + /** Position at the down edge. */ + readonly startX: number; + readonly startY: number; + /** Total travel since down. */ + readonly dx: number; + readonly dy: number; + /** Travel THIS frame (what a finger-follow drag consumes). */ + readonly fdx: number; + readonly fdy: number; + /** Velocity in logical px per VIRTUAL second (release velocity on up). */ + readonly vx: number; + readonly vy: number; + /** virtualFrame() at the down edge. */ + readonly downFrame: number; + /** Frames since down (0 on the down frame). */ + readonly frames: number; +} + +export interface GestureRegion { + /** Own contacts whose ink-claiming hit chain lands inside this node's + * subtree (spec op 27). */ + node?: () => NodeMirror | null | undefined; + /** Geometry fallback: used when the host lacks hitTest, and as the + * complement when the hit misses (nothing painted under the finger) + * inside the region. Logical px. */ + rect?: () => { x: number; y: number; w: number; h: number } | null | undefined; +} + +export interface GestureOptions { + /** Omit for a whole-screen recognizer (lowest specificity, not lowest + * priority — priority is registration order). */ + region?: GestureRegion; + /** Pan axis lock. "y"/"x" reject cross-axis movement (the contact's tap + * may still die, but this recognizer never pans it). Default "any". */ + axis?: "x" | "y" | "any"; + /** Max total travel (per axis) for the contact to still count as a tap. */ + tapSlop?: number; + /** Travel that starts a pan (and claims the contact). */ + panSlop?: number; + /** Hold duration for onLongPress, in VIRTUAL seconds. */ + longPressSeconds?: number; + /** Survive pushTouchBlock (the OSK's own recognizer sets this). */ + allowWhenBlocked?: boolean; + onDown?(c: GestureContact): void; + onMove?(c: GestureContact): void; + onUp?(c: GestureContact): void; + onCancel?(c: GestureContact): void; + /** Up within tapSlop, nothing claimed, no long-press fired. Single-fires + * on the highest-priority owner with a handler. */ + onTap?(c: GestureContact): void; + /** Held longPressSeconds within tapSlop. Fires once, then claims. */ + onLongPress?(c: GestureContact): void; + /** Slop exceeded on the (locked) axis — claims the contact. */ + onPanStart?(c: GestureContact): void; + /** Every frame while panning (fdx/fdy may be 0 on hold frames). */ + onPanMove?(c: GestureContact): void; + /** Release while panning; c.vx/vy is the fling velocity. */ + onPanEnd?(c: GestureContact): void; +} + +export interface GestureHandle { + dispose(): void; + /** Force-cancel this recognizer's in-flight contacts (fires onCancel). */ + cancel(): void; + /** True while any contact is mid-pan under this recognizer. */ + readonly panning: boolean; +} + +const MAX_TRACKS = 8; // the touch wire cap (touch.ts) +const HIST = 8; // position history ring length +const VELOCITY_WINDOW = 3; // frames spanned by the velocity estimate +const DEFAULT_TAP_SLOP = 8; +const DEFAULT_PAN_SLOP = 6; +const DEFAULT_LONG_PRESS_S = 0.5; + +// Per-(recognizer, pool slot) recognition state flags. +const OBSERVING = 1; +const TAP_DEAD = 2; +const LONGPRESS_FIRED = 4; +const PANNING = 8; +const PAN_DEAD = 16; + +interface Recognizer { + opts: GestureOptions; + disposed: boolean; + /** One flag byte per contact pool slot. */ + flags: Uint8Array; +} + +interface Track extends GestureContact { + slot: number; + used: boolean; + /** Seen in the current frame's snapshot (mark/sweep). */ + present: boolean; + id: number; + x: number; + y: number; + startX: number; + startY: number; + dx: number; + dy: number; + fdx: number; + fdy: number; + vx: number; + vy: number; + downFrame: number; + frames: number; + histX: Int16Array; + histY: Int16Array; + histHead: number; + histLen: number; + owners: Recognizer[]; + claimedBy: Recognizer | null; +} + +const recognizers: Recognizer[] = []; +let blockDepth = 0; +let liveCount = 0; + +const tracks: Track[] = Array.from({ length: MAX_TRACKS }, (_, slot) => ({ + slot, + used: false, + present: false, + id: 0, + x: 0, + y: 0, + startX: 0, + startY: 0, + dx: 0, + dy: 0, + fdx: 0, + fdy: 0, + vx: 0, + vy: 0, + downFrame: 0, + frames: 0, + histX: new Int16Array(HIST), + histY: new Int16Array(HIST), + histHead: 0, + histLen: 0, + owners: [], + claimedBy: null, +})); + +function withinSubtree(node: NodeMirror, ancestor: NodeMirror): boolean { + let n: NodeMirror | null = node; + while (n) { + if (n === ancestor) return true; + n = n.parent; + } + return false; +} + +/** Region match for a down at (x, y). `hit` is the memoized ink hit for this + * down: undefined = not yet computed, null = computed and missed/no op. */ +function regionMatches( + rec: Recognizer, + x: number, + y: number, + hitBox: { hit: NodeMirror | null | undefined }, +): boolean { + const region = rec.opts.region; + if (!region) return true; + const target = region.node?.(); + if (target) { + if (hitBox.hit === undefined) hitBox.hit = hitNode(x, y); + const hit = hitBox.hit; + if (hit) return withinSubtree(hit, target); + // Ink miss (or no hitTest op): the rect decides, when provided. A hit on + // ink OUTSIDE the subtree already returned above — occluders win. + } + const r = region.rect?.(); + if (!r) return false; + return x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h; +} + +function fireCancel(rec: Recognizer, t: Track): void { + rec.flags[t.slot] = 0; + if (t.claimedBy === rec) t.claimedBy = null; + rec.opts.onCancel?.(t); +} + +/** The winner keeps the contact; every other observing owner is cancelled. */ +function claim(t: Track, winner: Recognizer): void { + t.claimedBy = winner; + for (const o of t.owners) { + if (o !== winner && o.flags[t.slot] & OBSERVING) fireCancel(o, t); + } +} + +function releaseTrack(t: Track): void { + for (const o of t.owners) o.flags[t.slot] = 0; + t.owners.length = 0; + t.claimedBy = null; + t.used = false; + liveCount--; +} + +function beginTrack(t: Track, id: number, x: number, y: number): void { + t.used = true; + t.present = true; + t.id = id; + t.x = x; + t.y = y; + t.startX = x; + t.startY = y; + t.dx = 0; + t.dy = 0; + t.fdx = 0; + t.fdy = 0; + t.vx = 0; + t.vy = 0; + t.downFrame = virtualFrame(); + t.frames = 0; + t.histX[0] = x; + t.histY[0] = y; + t.histHead = 1; + t.histLen = 1; + t.owners.length = 0; + t.claimedBy = null; + liveCount++; + + // Resolve owners in priority order (last-registered first); the ink hit is + // computed at most once per down, shared across recognizers. + const hitBox: { hit: NodeMirror | null | undefined } = { hit: undefined }; + for (let i = recognizers.length - 1; i >= 0; i--) { + const rec = recognizers[i]; + if (rec.disposed) continue; + if (blockDepth > 0 && !rec.opts.allowWhenBlocked) continue; + if (!regionMatches(rec, x, y, hitBox)) continue; + rec.flags[t.slot] = OBSERVING; + t.owners.push(rec); + } + for (const o of t.owners) o.opts.onDown?.(t); +} + +function updateTrack(t: Track, x: number, y: number): void { + t.present = true; + t.fdx = x - t.x; + t.fdy = y - t.y; + t.x = x; + t.y = y; + t.dx = x - t.startX; + t.dy = y - t.startY; + t.frames++; + t.histX[t.histHead] = x; + t.histY[t.histHead] = y; + t.histHead = (t.histHead + 1) % HIST; + if (t.histLen < HIST) t.histLen++; + const k = Math.min(VELOCITY_WINDOW, t.histLen - 1); + if (k <= 0) { + t.vx = 0; + t.vy = 0; + return; + } + // Integer px over k frames of 1/hz virtual seconds each — px per virtual + // second with a single exactly-specified IEEE division per axis. + const hz = simulationHz(); + const last = (t.histHead - 1 + HIST) % HIST; + const prev = (last - k + HIST) % HIST; + t.vx = ((t.histX[last] - t.histX[prev]) * hz) / k; + t.vy = ((t.histY[last] - t.histY[prev]) * hz) / k; +} + +function recognize(t: Track): void { + const moved = t.fdx !== 0 || t.fdy !== 0; + const adx = t.dx < 0 ? -t.dx : t.dx; + const ady = t.dy < 0 ? -t.dy : t.dy; + + for (const rec of t.owners) { + const f = rec.flags[t.slot]; + if (!(f & OBSERVING)) continue; + if (moved) rec.opts.onMove?.(t); + // Tap death is per-owner: each recognizer has its own slop. + const slop = rec.opts.tapSlop ?? DEFAULT_TAP_SLOP; + if (!(f & TAP_DEAD) && (adx > slop || ady > slop)) { + rec.flags[t.slot] |= TAP_DEAD; + } + } + + // Long-press: first (highest-priority) owner still tap-alive past its + // deadline fires once, then claims. + if (!t.claimedBy) { + for (const rec of t.owners) { + const f = rec.flags[t.slot]; + if (!(f & OBSERVING) || f & (TAP_DEAD | LONGPRESS_FIRED)) continue; + if (!rec.opts.onLongPress) continue; + const deadline = Math.max( + 1, + Math.round((rec.opts.longPressSeconds ?? DEFAULT_LONG_PRESS_S) * simulationHz()), + ); + if (t.frames < deadline) continue; + rec.flags[t.slot] |= LONGPRESS_FIRED; + rec.opts.onLongPress(t); + claim(t, rec); + break; + } + } + + // Pan start: first owner whose (locked) axis crosses slop claims. + if (!t.claimedBy) { + for (const rec of t.owners) { + const f = rec.flags[t.slot]; + if (!(f & OBSERVING) || f & (PANNING | PAN_DEAD)) continue; + if (!rec.opts.onPanStart && !rec.opts.onPanMove && !rec.opts.onPanEnd) continue; + const slop = rec.opts.panSlop ?? DEFAULT_PAN_SLOP; + if (adx <= slop && ady <= slop) continue; + const axis = rec.opts.axis ?? "any"; + if (axis === "y" ? ady < adx : axis === "x" ? adx < ady : false) { + // Dominant axis is the wrong one — this recognizer never pans this + // contact (a horizontal swipe over a vertical list stays a swipe). + rec.flags[t.slot] |= PAN_DEAD; + continue; + } + rec.flags[t.slot] |= PANNING | TAP_DEAD; + rec.opts.onPanStart?.(t); + claim(t, rec); + break; + } + } + + for (const rec of t.owners) { + if ((rec.flags[t.slot] & (OBSERVING | PANNING)) === (OBSERVING | PANNING)) { + rec.opts.onPanMove?.(t); + } + } +} + +function finishTrack(t: Track): void { + for (const rec of t.owners) { + const f = rec.flags[t.slot]; + if (!(f & OBSERVING)) continue; + rec.opts.onUp?.(t); + if (f & PANNING) rec.opts.onPanEnd?.(t); + } + // Tap single-fires on the highest-priority owner still qualifying. + if (!t.claimedBy) { + for (const rec of t.owners) { + const f = rec.flags[t.slot]; + if (!(f & OBSERVING) || f & (TAP_DEAD | LONGPRESS_FIRED | PANNING)) continue; + if (!rec.opts.onTap) continue; + rec.opts.onTap(t); + break; + } + } + releaseTrack(t); +} + +/** One gesture frame. Called from the frame pump (index.ts) after + * __setTouches/__drainEffects and before app frame hooks. */ +export function __runGestures(): void { + const snap = touches(); + if (snap.length === 0 && liveCount === 0) return; + + for (const t of tracks) t.present = false; + + for (const c of snap) { + let found: Track | null = null; + for (const t of tracks) { + if (t.used && t.id === c.id) { + found = t; + break; + } + } + if (found) { + updateTrack(found, c.x, c.y); + continue; + } + let free: Track | null = null; + for (const t of tracks) { + if (!t.used) { + free = t; + break; + } + } + if (free) beginTrack(free, c.id, c.x, c.y); + } + + // Up edges first (a released contact must not be re-recognized), then the + // per-frame recognition pass over surviving contacts. + for (const t of tracks) { + if (t.used && !t.present) finishTrack(t); + } + for (const t of tracks) { + if (t.used && t.present) recognize(t); + } +} + +function cancelContactsFor(rec: Recognizer): void { + for (const t of tracks) { + if (t.used && rec.flags[t.slot] & OBSERVING) fireCancel(rec, t); + } +} + +/** + * Register a recognizer. Framework-neutral: the caller owns disposal. Most + * component code wants createGesture() below, which scopes disposal to the + * owner's onCleanup. + */ +export function attachGesture(opts: GestureOptions): GestureHandle { + const rec: Recognizer = { opts, disposed: false, flags: new Uint8Array(MAX_TRACKS) }; + recognizers.push(rec); + return { + dispose(): void { + if (rec.disposed) return; + cancelContactsFor(rec); + rec.disposed = true; + const i = recognizers.lastIndexOf(rec); + if (i >= 0) recognizers.splice(i, 1); + }, + cancel(): void { + if (!rec.disposed) cancelContactsFor(rec); + }, + get panning(): boolean { + for (const t of tracks) { + if (t.used && rec.flags[t.slot] & PANNING) return true; + } + return false; + }, + }; +} + +/** attachGesture + onCleanup(dispose) for Solid component scopes. */ +export function createGesture(opts: GestureOptions): GestureHandle { + const handle = attachGesture(opts); + onCleanup(() => handle.dispose()); + return handle; +} + +/** + * Modal touch mute — the touch mirror of pushButtonHandlerBlock (frame.ts). + * Pushing SYNCHRONOUSLY cancels the in-flight contacts of every non-exempt + * recognizer (the list under an opening OSK sees onCancel this frame, not a + * phantom release later) and suppresses new downs for them while held. + * Recognizers with allowWhenBlocked keep working. Returns a disposer. + */ +export function pushTouchBlock(): () => void { + blockDepth++; + for (const rec of recognizers) { + if (!rec.opts.allowWhenBlocked) cancelContactsFor(rec); + } + let disposed = false; + return () => { + if (disposed) return; + disposed = true; + blockDepth = Math.max(0, blockDepth - 1); + }; +} + +/** Fresh gesture state for a fresh mount (index.ts render()/dispose). */ +export function resetGestures(): void { + recognizers.length = 0; + blockDepth = 0; + liveCount = 0; + for (const t of tracks) { + t.used = false; + t.present = false; + t.owners.length = 0; + t.claimedBy = null; + } +} diff --git a/framework/src/index.ts b/framework/src/index.ts index d915d2c2..30816ecc 100644 --- a/framework/src/index.ts +++ b/framework/src/index.ts @@ -40,6 +40,7 @@ import { import { setOverlayRoot } from "./overlay.ts"; import { registerStyles, resolveStyle } from "./styles.ts"; import { handleFrame, setHitRoot, setInputRoot } from "./input.ts"; +import { __runGestures, resetGestures } from "./gesture.ts"; import { __setAnalog, resetFrameHooks, runFrameHooks } from "./frame.ts"; import { __resetTouches, __setTouches } from "./touch.ts"; import { __advanceClock, resetClock } from "./clock.ts"; @@ -233,6 +234,7 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi setInputRoot(appRoot); setHitRoot(rootMirror); // hit tests see the overlay layer too resetFrameHooks(); + resetGestures(); resetClock(); // latches the host's __simHz clock policy (docs/DETERMINISM.md) resetEffects(); initDevtools(host.ops); // DevTools shim (docs/DEVTOOLS.md): flight recorder + @@ -243,6 +245,7 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi __setAnalog(analog); // latch the nub before any app code reads it __setTouches(touches); // latch logical front-panel contacts for this frame __drainEffects(); // frame-boundary deliveries enter the world first + __runGestures(); // contact lifecycles resolve before app hooks read them runFrameHooks(buttons); // app lifecycle callbacks: onFrame/onButtonPress/etc. handleFrame(buttons); // edge-detect, focus nav, onPress (runs effects) runSweep(); // then destroy subtrees still detached [R] @@ -252,6 +255,7 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi const dispose = rendererRender(code as () => NodeMirror, appRoot); return () => { __resetTouches(); + resetGestures(); dispose(); // tears down reactivity only — universal keeps the nodes setInputRoot(null); // drops focus state (native focus dies with the nodes) setHitRoot(null); diff --git a/framework/src/input-api.ts b/framework/src/input-api.ts index 8ac4ae8c..4fb5ddca 100644 --- a/framework/src/input-api.ts +++ b/framework/src/input-api.ts @@ -9,9 +9,12 @@ export { focusNode, getFocused, hitFocusable, + hitNode, + pressNode, pushFocusController, pushFocusGrid, pushFocusScope, + setActiveNode, type CursorOptions, type FocusDirection, type FocusGridOptions, diff --git a/framework/src/input.ts b/framework/src/input.ts index 1a74d8da..f8c791bb 100644 --- a/framework/src/input.ts +++ b/framework/src/input.ts @@ -285,7 +285,11 @@ function moveFocus(direction: FocusDirection): void { } function firePress(): void { - let n: NodeMirror | null = focused; + firePressFrom(focused); +} + +function firePressFrom(start: NodeMirror | null): void { + let n: NodeMirror | null = start; while (n) { if (n.onPress) { n.onPress(); @@ -295,6 +299,21 @@ function firePress(): void { } } +/** Hold/clear the `active:` pressed variant from touch/gesture code. All + * writers (d-pad, cursor, touch) route through the same internal latch, so + * a pressed look can never strand across input modes. */ +export function setActiveNode(node: NodeMirror | null): void { + setPressedNode(node); +} + +/** Focus a node and fire its onPress (bubbling to the nearest ancestor + * handler) — the touch-tap equivalent of the CIRCLE press, so taps, d-pad, + * and cursor clicks all land in the same handler. */ +export function pressNode(node: NodeMirror): void { + focusNode(node); + firePressFrom(node); +} + // ---- removal repair [R] ------------------------------------------------------ function isWithin(node: NodeMirror, ancestor: NodeMirror): boolean { @@ -669,6 +688,18 @@ export function hitFocusable(x: number, y: number): NodeMirror | null { return cursorTarget(findMirror(hitRoot ?? root, ops.hitTest(x, y))); } +/** + * Raw topmost-ink mirror under a screen point — hitFocusable without the + * focusable/scope filter. The gesture layer resolves region ownership with + * this (a pan region is rarely focusable itself). Null when the host has no + * hitTest op or nothing painted claims the point. + */ +export function hitNode(x: number, y: number): NodeMirror | null { + const ops = getOps(); + if (!ops.hitTest) return null; + return findMirror(hitRoot ?? root, ops.hitTest(x, y)); +} + /** One cursor-mode frame. Returns false when the host predates the cursor * ops — the caller then falls through to the classic d-pad model, so a * stale host never loses input. */ diff --git a/package.json b/package.json index c75dff0c..b5c4aaa4 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "./components": "./framework/src/components.ts", "./devtools": "./framework/src/devtools.ts", "./effects": "./framework/src/effects.ts", + "./gesture": "./framework/src/gesture.ts", "./host": "./framework/src/host.ts", "./lifecycle": "./framework/src/lifecycle.ts", "./hot": "./framework/src/hot.ts", diff --git a/tests/gesture.test.ts b/tests/gesture.test.ts new file mode 100644 index 00000000..2ae5bfa4 --- /dev/null +++ b/tests/gesture.test.ts @@ -0,0 +1,305 @@ +// Gesture layer unit tests: drive __setTouches + __runGestures directly +// (the tests/touch.test.ts pattern) — no host, no renderer. Region-based +// ownership through hitTest is exercised at the renderer/sim level; these +// tests use rect regions and whole-screen recognizers. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + attachGesture, + pushTouchBlock, + resetGestures, + __runGestures, + type GestureContact, +} from "../framework/src/gesture.ts"; +import { __packTouch, __resetTouches, __setTouches } from "../framework/src/touch.ts"; +import { resetClock, __advanceClock } from "../framework/src/clock.ts"; + +type Contact = [id: number, x: number, y: number]; + +/** One frame: advance the clock, latch contacts, run the pump. */ +function pump(contacts: readonly Contact[]): void { + __advanceClock(); + __setTouches(contacts.map(([id, x, y]) => __packTouch(id, x, y))); + __runGestures(); +} + +function withHz(hz: number): void { + (globalThis as { __simHz?: number }).__simHz = hz; + resetClock(); +} + +beforeEach(() => { + delete (globalThis as { __simHz?: number }).__simHz; + resetClock(); + resetGestures(); + __resetTouches(); +}); + +afterEach(() => { + resetGestures(); + __resetTouches(); +}); + +describe("contact lifecycle", () => { + test("down/move/up edges with delta and history bookkeeping", () => { + const log: string[] = []; + attachGesture({ + onDown: (c) => log.push(`down ${c.id} ${c.x},${c.y}`), + onMove: (c) => log.push(`move ${c.dx},${c.dy} f${c.fdx},${c.fdy}`), + onUp: (c) => log.push(`up ${c.x},${c.y} frames=${c.frames}`), + }); + pump([[1, 100, 50]]); + pump([[1, 103, 50]]); + pump([[1, 103, 50]]); // hold frame: no move edge + pump([[1, 110, 60]]); + pump([]); + expect(log).toEqual([ + "down 1 100,50", + "move 3,0 f3,0", + "move 10,10 f7,10", + "up 110,60 frames=3", + ]); + }); + + test("id reuse after a release starts a fresh contact", () => { + const downs: number[] = []; + attachGesture({ onDown: (c) => downs.push(c.startX) }); + pump([[1, 10, 10]]); + pump([]); + pump([[1, 200, 10]]); + expect(downs).toEqual([10, 200]); + }); + + test("tracks cap at the 8-contact pool without dropping existing ones", () => { + let downs = 0; + attachGesture({ onDown: () => downs++ }); + pump(Array.from({ length: 12 }, (_, i) => [i, i, i] as Contact)); + expect(downs).toBe(8); + }); +}); + +describe("tap", () => { + test("fires within slop; movement beyond slop kills it", () => { + const taps: number[] = []; + attachGesture({ onTap: (c) => taps.push(c.id) }); + // 8 px total travel = still a tap (slop is exclusive). + pump([[1, 100, 100]]); + pump([[1, 108, 100]]); + pump([]); + // 9 px kills it. + pump([[2, 100, 100]]); + pump([[2, 109, 100]]); + pump([]); + expect(taps).toEqual([1]); + }); + + test("single-fires on the last-registered owner", () => { + const fired: string[] = []; + attachGesture({ onTap: () => fired.push("first") }); + attachGesture({ onTap: () => fired.push("second") }); + pump([[1, 50, 50]]); + pump([]); + expect(fired).toEqual(["second"]); + }); +}); + +describe("velocity", () => { + test("is exact: 4 px/frame at 60 Hz reads 240 px/s", () => { + const seen: Array<{ vx: number; vy: number }> = []; + attachGesture({ onMove: (c) => seen.push({ vx: c.vx, vy: c.vy }) }); + pump([[1, 100, 100]]); + pump([[1, 104, 100]]); + pump([[1, 108, 100]]); + pump([[1, 112, 100]]); // k = 3 window is full + expect(seen[seen.length - 1]).toEqual({ vx: 240, vy: 0 }); + }); + + test("scales with simulationHz (px per VIRTUAL second)", () => { + withHz(30); + const seen: number[] = []; + attachGesture({ onMove: (c) => seen.push(c.vy) }); + pump([[1, 100, 100]]); + pump([[1, 100, 110]]); + pump([[1, 100, 120]]); + pump([[1, 100, 130]]); + // 30 px over 3 frames of 1/30 s each = 300 px/s. + expect(seen[seen.length - 1]).toBe(300); + }); + + test("release velocity reaches onPanEnd", () => { + const ends: number[] = []; + attachGesture({ axis: "y", onPanEnd: (c) => ends.push(c.vy) }); + pump([[1, 100, 100]]); + pump([[1, 100, 110]]); + pump([[1, 100, 120]]); + pump([[1, 100, 130]]); + pump([]); + expect(ends).toEqual([600]); + }); +}); + +describe("pan", () => { + test("claims the contact and cancels sibling owners", () => { + const log: string[] = []; + attachGesture({ + onDown: () => log.push("tap:down"), + onCancel: () => log.push("tap:cancel"), + onTap: () => log.push("tap:tap"), + }); + attachGesture({ + axis: "y", + onPanStart: (c) => log.push(`pan:start ${c.dy}`), + onPanMove: (c) => log.push(`pan:move ${c.fdy}`), + onPanEnd: () => log.push("pan:end"), + onDown: () => log.push("pan:down"), + }); + pump([[1, 100, 100]]); + pump([[1, 100, 107]]); // dy 7 > panSlop 6 → pan claims + pump([[1, 100, 117]]); + pump([]); + expect(log).toEqual([ + "pan:down", + "tap:down", + "pan:start 7", + "tap:cancel", // losers cancel at claim time, before further pan events + "pan:move 7", + "pan:move 10", + "pan:end", + ]); + }); + + test("axis lock rejects cross-axis movement without claiming", () => { + const log: string[] = []; + attachGesture({ + axis: "y", + onPanStart: () => log.push("pan:start"), + onCancel: () => log.push("pan:cancel"), + }); + attachGesture({ axis: "x", onPanStart: () => log.push("hpan:start") }); + pump([[1, 100, 100]]); + pump([[1, 110, 101]]); // dominant axis is x + expect(log).toEqual(["hpan:start", "pan:cancel"]); + }); + + test("onPanMove fires on hold frames so finger-follow sees every frame", () => { + let moves = 0; + attachGesture({ axis: "y", onPanMove: () => moves++ }); + pump([[1, 100, 100]]); + pump([[1, 100, 110]]); + pump([[1, 100, 110]]); + pump([[1, 100, 110]]); + expect(moves).toBe(3); + }); +}); + +describe("long-press", () => { + test("fires at the virtual-frame deadline and claims", () => { + const log: string[] = []; + attachGesture({ onCancel: () => log.push("tap:cancel"), onTap: () => log.push("tap:tap") }); + attachGesture({ onLongPress: () => log.push("lp"), longPressSeconds: 0.1 }); + pump([[1, 100, 100]]); + for (let i = 0; i < 5; i++) pump([[1, 100, 100]]); // frames 1..5 + expect(log).toEqual([]); + pump([[1, 100, 100]]); // frame 6 = round(0.1 * 60) + expect(log).toEqual(["lp", "tap:cancel"]); + pump([]); + expect(log).toEqual(["lp", "tap:cancel"]); // no tap after a long-press + }); + + test("deadline follows simulationHz", () => { + withHz(30); + let fired = 0; + attachGesture({ onLongPress: () => fired++, longPressSeconds: 0.1 }); + pump([[1, 100, 100]]); + pump([[1, 100, 100]]); + pump([[1, 100, 100]]); + expect(fired).toBe(0); + pump([[1, 100, 100]]); // frame 3 = round(0.1 * 30)... deadline 3, frames counter hits 3 here + expect(fired).toBe(1); + }); + + test("movement beyond slop disarms it", () => { + let fired = 0; + attachGesture({ onLongPress: () => fired++, longPressSeconds: 0.05 }); + pump([[1, 100, 100]]); + pump([[1, 120, 100]]); + for (let i = 0; i < 10; i++) pump([[1, 120, 100]]); + expect(fired).toBe(0); + }); +}); + +describe("regions", () => { + test("rect region gates ownership", () => { + const inside: number[] = []; + attachGesture({ + region: { rect: () => ({ x: 0, y: 100, w: 480, h: 100 }) }, + onDown: (c) => inside.push(c.y), + }); + pump([[1, 50, 150]]); + pump([]); + pump([[2, 50, 50]]); // above the rect + expect(inside).toEqual([150]); + }); +}); + +describe("pushTouchBlock", () => { + test("cancels in-flight contacts and suppresses new downs; exempt recognizers keep working", () => { + const log: string[] = []; + attachGesture({ + onDown: () => log.push("app:down"), + onCancel: () => log.push("app:cancel"), + onMove: () => log.push("app:move"), + }); + attachGesture({ + allowWhenBlocked: true, + onDown: () => log.push("osk:down"), + onMove: () => log.push("osk:move"), + }); + pump([[1, 100, 100]]); + const pop = pushTouchBlock(); + expect(log).toEqual(["osk:down", "app:down", "app:cancel"]); + pump([[1, 100, 120]]); // held contact still tracked for the exempt owner + pump([[2, 50, 50]]); // new down while blocked + expect(log).toEqual(["osk:down", "app:down", "app:cancel", "osk:move", "osk:down"]); + pop(); + pump([]); + pump([[3, 10, 10]]); + expect(log[log.length - 2]).toBe("osk:down"); + expect(log[log.length - 1]).toBe("app:down"); + }); +}); + +describe("handles", () => { + test("dispose cancels in-flight contacts and unregisters", () => { + const log: string[] = []; + const h = attachGesture({ + onDown: () => log.push("down"), + onCancel: () => log.push("cancel"), + }); + pump([[1, 100, 100]]); + h.dispose(); + expect(log).toEqual(["down", "cancel"]); + pump([[2, 50, 50]]); + expect(log).toEqual(["down", "cancel"]); + }); + + test("panning reflects an in-flight pan", () => { + const h = attachGesture({ axis: "y", onPanMove: () => {} }); + expect(h.panning).toBe(false); + pump([[1, 100, 100]]); + pump([[1, 100, 120]]); + expect(h.panning).toBe(true); + pump([]); + expect(h.panning).toBe(false); + }); + + test("cancelled contact delivers a final GestureContact view", () => { + const cancelled: GestureContact[] = []; + const h = attachGesture({ onCancel: (c) => cancelled.push(c) }); + pump([[1, 100, 100]]); + pump([[1, 105, 100]]); + h.cancel(); + expect(cancelled).toHaveLength(1); + expect(cancelled[0].dx).toBe(5); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index a58deab5..ccf8d8b4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ "@pocketjs/framework/animation": ["./framework/src/animation.ts"], "@pocketjs/framework/config": ["./framework/src/config.ts"], "@pocketjs/framework/components": ["./framework/src/components.ts"], + "@pocketjs/framework/gesture": ["./framework/src/gesture.ts"], "@pocketjs/framework/input": ["./framework/src/input-api.ts"], "@pocketjs/framework/lifecycle": ["./framework/src/lifecycle.ts"], "@pocketjs/framework/manifest": ["./framework/src/manifest/index.ts"],