From 3526b78a0961a4c93dc3c7776d2892f4aa8cf23a Mon Sep 17 00:00:00 2001 From: Chengyu Wu <7086cmd@gmail.com> Date: Thu, 6 Aug 2026 10:18:54 +0800 Subject: [PATCH 1/2] feat(input): implement input.pointer as a cursor position source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform contracts have registered `input.pointer` since the desktop surface landed — "a REAL absolute pointer (mouse/trackpad): position plus press/drag/release edges, hover resolves focus", explicitly distinct from `input.cursor`'s nub-synthesized pointer. Nothing in the framework consumed it: the only worked example, examples/note-widget, routes its mouse through an app-specific svc channel and maps clicks to CIRCLE by hand, so every host with a real pointer has to reinvent hover and hit-resolution in app code. Give the capability a framework implementation by making the cursor's position source configurable: enableCursor({ source: "pointer" }) In that mode the host's absolute position drives the existing cursor state machine and the pointer's own button supplies the press/release edges, so hover, `focus:`, `active:`, press arming and onPress behave exactly as they already do — only the position source and the edge source change. `speed`, `dpadSpeed` and `button` do not apply. The default stays "analog", so every existing app, host, tape and golden is untouched. The host passes position and button state as a 4th frame argument, packed `(down << 20) | (y << 10) | x` (10 bits per axis), mirroring how `touches` was added. `undefined` means the host has no pointer this frame — a pointer that left the window — which is deliberately distinguishable from one parked at the origin: the cursor holds its last position and an in-flight press stays armed until a real release. Replay scrubs the pointer exactly like touch, so tapes stay deterministic. Why a frame argument rather than a polled HostOps method: a pull-based op would not be captured by the flight recorder, and every input the guest sees has to be on the tape for `tape:check` to mean anything. pocket-mod gains `Guest::frame_with_pointer` for native hosts. Coverage: 7 cursor tests for the pointer source (adoption vs. nub steering, hover-focus with no button, press/hold/release firing once, cancel on release away from the armed node, the PSP mask NOT clicking a host-driven cursor, pointer-left-the-window, clamping), 5 pointer.ts snapshot tests, and a pocket-mod round-trip. `bun run tape:check` still matches all 180 frames. --- engine/crates/pocket-mod/src/lib.rs | 64 ++++++++++++++ framework/src/devtools.ts | 29 +++++-- framework/src/host.ts | 14 +++- framework/src/index-vue-vapor.ts | 10 ++- framework/src/index.ts | 10 ++- framework/src/input-api.ts | 2 + framework/src/input.ts | 116 +++++++++++++++++++------ framework/src/pointer.ts | 62 ++++++++++++++ package.json | 2 +- tests/cursor.test.ts | 126 ++++++++++++++++++++++++++++ tests/pointer.test.ts | 44 ++++++++++ 11 files changed, 443 insertions(+), 36 deletions(-) create mode 100644 framework/src/pointer.ts create mode 100644 tests/pointer.test.ts diff --git a/engine/crates/pocket-mod/src/lib.rs b/engine/crates/pocket-mod/src/lib.rs index d166cd74..77b8bda5 100644 --- a/engine/crates/pocket-mod/src/lib.rs +++ b/engine/crates/pocket-mod/src/lib.rs @@ -136,6 +136,44 @@ impl Guest { Ok(()) } + /// One guest turn with touch contacts and a real absolute pointer. + /// + /// `pointer` packs the host's mouse/trackpad as + /// `(down << 20) | (y << 10) | x` (framework/src/pointer.ts): 10 bits per + /// axis, so logical coordinates must be ≤ 1023. `None` means the host has + /// no pointer this frame — a pointer that left the window — and leaves the + /// guest's cursor parked rather than snapping it to an origin. + /// + /// This is the 4-arg `globalThis.frame(buttons, analog, touches, pointer)` + /// path for `input.pointer` targets. Hosts whose pointer is a panel + /// contact rather than a hovering device use [`Guest::frame_with_touches`]. + pub fn frame_with_pointer( + &self, + buttons: u32, + analog: u32, + touches: &[u32], + pointer: Option, + ) -> Result<()> { + self.ctx.with(|ctx| -> Result<()> { + let frame: Option = ctx.globals().get("frame").ok(); + if let Some(frame) = frame { + let arr = rquickjs::Array::new(ctx.clone()) + .map_err(|e| anyhow!("pocket-mod: allocating touch array: {e}"))?; + for (i, t) in touches.iter().enumerate() { + arr.set(i, *t) + .map_err(|e| anyhow!("pocket-mod: setting touch {i}: {e}"))?; + } + frame + .call::<_, ()>((buttons, analog, arr, pointer)) + .catch(&ctx) + .map_err(|e| anyhow!("pocket-mod: frame() threw: {e}"))?; + } + Ok(()) + })?; + self.drain_jobs(); + Ok(()) + } + /// Drain the microtask/job queue (promise reactions). Job exceptions are /// logged, not fatal — matching how hosts treat stray rejections. pub fn drain_jobs(&self) { @@ -321,6 +359,32 @@ mod tests { assert_eq!(res, "0:0:-1"); } + #[test] + fn frame_carries_a_packed_pointer() { + let g = Guest::new().unwrap(); + g.eval( + "boot", + "globalThis.res = ''; \ + globalThis.frame = (b, a, t, p) => { \ + globalThis.res = b + ':' + (p === undefined ? 'none' : p); \ + };", + ) + .unwrap(); + // (down<<20)|(y<<10)|x — pointer held at logical (10, 20): + let packed = (1u32 << 20) | (20 << 10) | 10; + g.frame_with_pointer(0, pocketjs_core::spec::ANALOG_CENTER, &[], Some(packed)) + .unwrap(); + let res: String = g.with(|ctx| ctx.globals().get("res").unwrap()); + assert_eq!(res, format!("0:{packed}")); + + // A host whose pointer left the window passes none, and the guest must + // be able to tell that apart from a pointer parked at the origin. + g.frame_with_pointer(0, pocketjs_core::spec::ANALOG_CENTER, &[], None) + .unwrap(); + let res: String = g.with(|ctx| ctx.globals().get("res").unwrap()); + assert_eq!(res, "0:none"); + } + #[test] fn exceptions_carry_js_stack() { let g = Guest::new().unwrap(); diff --git a/framework/src/devtools.ts b/framework/src/devtools.ts index 84f483ca..24ea45e3 100644 --- a/framework/src/devtools.ts +++ b/framework/src/devtools.ts @@ -159,9 +159,24 @@ 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[], + pointer?: number, + ) => void, +): ( + buttons: number, + analog?: number, + touches?: readonly number[], + pointer?: number, +) => void { + return ( + buttons: number, + analogArg?: number, + touchArg?: readonly number[], + pointerArg?: number, + ) => { state.hostCalls++; if (state.transport) { pollTransport(); @@ -170,13 +185,15 @@ export function wrapFrameHandler( let mask = buttons; let analog = analogArg === undefined ? ANALOG_CENTER : analogArg & 0xffff; let touch = touchArg; + let point = pointerArg; if (state.replayMasks) { if (state.replayAt < state.replayMasks.length) { mask = state.replayMasks[state.replayAt]; analog = state.replayAnalog ? state.replayAnalog[state.replayAt] : ANALOG_CENTER; - // A replay that predates touch input must not leak live hardware state - // into the deterministic tape. + // A replay that predates touch/pointer input must not leak live + // hardware state into the deterministic tape. touch = undefined; + point = undefined; state.replayAt++; } else { state.replayMasks = null; // tape exhausted: back to live input @@ -192,7 +209,7 @@ export function wrapFrameHandler( recordMask(mask, analog); state.frame++; try { - h(mask, analog, touch); + h(mask, analog, touch, point); } catch (e) { send({ t: "error", diff --git a/framework/src/host.ts b/framework/src/host.ts index 099a0fd2..1bc87e68 100644 --- a/framework/src/host.ts +++ b/framework/src/host.ts @@ -303,10 +303,20 @@ export function getOps(): HostOps { // via installFrameHandler. export function installFrameHandler( - fn: (buttons: number, analog?: number, touches?: readonly number[]) => void, + fn: ( + buttons: number, + analog?: number, + touches?: readonly number[], + pointer?: number, + ) => void, ): void { (globalThis as { - frame?: (buttons: number, analog?: number, touches?: readonly number[]) => void; + frame?: ( + buttons: number, + analog?: number, + touches?: readonly number[], + pointer?: number, + ) => void; }).frame = fn; } diff --git a/framework/src/index-vue-vapor.ts b/framework/src/index-vue-vapor.ts index 81a5590f..62293ea4 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 { __resetPointer, __setPointer } from "./pointer.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"; @@ -201,10 +202,16 @@ export function render(code: VaporRenderRoot, opts: RenderOptions = {}): () => v 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[], + pointer?: number, + ) => { __advanceClock(); __setAnalog(analog); __setTouches(touches); + __setPointer(pointer); __drainEffects(); runFrameHooks(buttons); handleFrame(buttons); @@ -217,6 +224,7 @@ export function render(code: VaporRenderRoot, opts: RenderOptions = {}): () => v return () => { removeResizeViewportHook(); __resetTouches(); + __resetPointer(); dispose(); setInputRoot(null); setOverlayRoot(null); diff --git a/framework/src/index.ts b/framework/src/index.ts index fe86fb5b..e6ae72b7 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 { __resetPointer, __setPointer } from "./pointer.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"; @@ -255,10 +256,16 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi 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[], + pointer?: 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 + __setPointer(pointer); // latch the host's absolute pointer (input.pointer) __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,6 +278,7 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi return () => { removeResizeViewportHook(); __resetTouches(); + __resetPointer(); 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..049b24e1 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 { pointer, type PointerSnapshot } from "./pointer.ts"; export { cursorX, cursorY, @@ -13,6 +14,7 @@ export { pushFocusGrid, pushFocusScope, type CursorOptions, + type CursorSource, type FocusDirection, type FocusGridOptions, type FocusScopeOptions, diff --git a/framework/src/input.ts b/framework/src/input.ts index 1a74d8da..d8aeb138 100644 --- a/framework/src/input.ts +++ b/framework/src/input.ts @@ -34,12 +34,29 @@ // - While the cursor is enabled, d-pad focus traversal and the CIRCLE // press of the classic model are suppressed; onButtonPress hooks are // untouched (they run in frame.ts before this module). +// +// Host pointer mode (input.pointer capability, enableCursor({source: +// "pointer"})): +// - A REAL absolute pointer (mouse/trackpad) drives the same cursor state +// machine, so hover/press/`active:`/onPress behave identically — only the +// position source and the press edge change. The host passes position and +// button state as the 4th frame argument (framework/src/pointer.ts); the +// nub, `speed`, `dpadSpeed` and `button` do not apply. +// - This is a different guarantee from input.cursor, which SYNTHESIZES a +// pointer from the nub: a real one reports hover, so `focus:` styles track +// the mouse before any click, and clicks land where the user is pointing +// rather than on whatever the d-pad last focused. +// - A frame with no pointer (it left the window) holds the last position and +// reports no edge, so an in-flight press stays armed until a real release. +// - Replay scrubs the pointer exactly like touch, so tapes stay +// deterministic (docs/DETERMINISM.md). import { BTN, IMG_FLAG_RLE, PSM, SCREEN_H, SCREEN_W } from "../../contracts/spec/spec.ts"; import { ticksPerFrame } from "./clock.ts"; import { analogX, analogY } from "./frame.ts"; import { getHost, getOps, hostViewport, type HostOps } from "./host.ts"; import { get as pakGet } from "./pak.ts"; +import { pointer } from "./pointer.ts"; import type { NodeMirror } from "./renderer.ts"; let root: NodeMirror | null = null; @@ -67,6 +84,7 @@ export function setInputRoot(r: NodeMirror | null): void { focusControllerStack.length = 0; if (cursor) { cursor.pressTarget = null; + cursor.prevDown = false; cursor.target = null; cursor.spriteDirty = true; cursor.fresh = true; @@ -423,11 +441,27 @@ export interface CursorOptions { /** Also steer with the d-pad at this px/s while the nub is centered * (0, the default, leaves the d-pad to the app). */ dpadSpeed?: number; - /** Button mask that presses/clicks the hovered node. Default CIRCLE. */ + /** Button mask that presses/clicks the hovered node. Default CIRCLE. + * Ignored when `source` is "pointer" — a real pointer carries its own + * button state. */ button?: number; /** Starting position. Default: viewport center. */ start?: [number, number]; -} + /** + * Where the cursor's position comes from. + * + * - "analog" (default) — the nub steers it as a velocity (input.cursor). + * - "pointer" — the host's REAL absolute pointer drives it, and its button + * supplies the press/release edges (input.pointer). `speed`, `dpadSpeed` + * and `button` do not apply. Hosts that never pass the pointer frame + * argument leave the cursor parked, so declaring "pointer" on a host + * without one degrades to a stationary cursor rather than breaking. + */ + source?: CursorSource; +} + +/** Position source for the virtual cursor. */ +export type CursorSource = "analog" | "pointer"; interface CursorState { /** Position; -1 until the first frame centers it in the viewport. */ @@ -439,6 +473,10 @@ interface CursorState { speed: number; dpadSpeed: number; button: number; + source: CursorSource; + /** Previous frame's pointer button state, for edge detection in "pointer" + * mode. Unused when the nub steers. */ + prevDown: boolean; /** Armed by the press edge; fires on release while still hovered. */ pressTarget: NodeMirror | null; /** Uploaded sprite texture (-1 until the lazy first-frame init). */ @@ -543,6 +581,8 @@ export function enableCursor(opts: CursorOptions = {}): () => void { speed: opts.speed ?? 240, dpadSpeed: opts.dpadSpeed ?? 0, button: opts.button ?? BTN.CIRCLE, + source: opts.source ?? prev?.source ?? "analog", + prevDown: prev?.prevDown ?? false, pressTarget: null, tex: prev ? prev.tex : -1, sprite, @@ -688,25 +728,51 @@ function cursorFrame(buttons: number, pressed: number, released: number): boolea } if (c.spriteDirty) cursorInitSprite(c, ops); - // -- steer: px per VIRTUAL second, hz-invariant via the tick count ------- - let vx = analogX() * c.speed; - let vy = analogY() * c.speed; - if (c.dpadSpeed > 0 && vx === 0 && vy === 0) { - if (buttons & BTN.LEFT) vx = -c.dpadSpeed; - if (buttons & BTN.RIGHT) vx = c.dpadSpeed; - if (buttons & BTN.UP) vy = -c.dpadSpeed; - if (buttons & BTN.DOWN) vy = c.dpadSpeed; - } + // -- steer ----------------------------------------------------------------- + // "analog": px per VIRTUAL second, hz-invariant via the tick count. + // "pointer": the host's absolute position, adopted as-is. let moved = c.fresh; - if (vx !== 0 || vy !== 0) { - const dt = ticksPerFrame() / 60; - const nx = Math.min(Math.max(c.x + vx * dt, 0), c.vw - 1); - const ny = Math.min(Math.max(c.y + vy * dt, 0), c.vh - 1); - if (nx !== c.x || ny !== c.y) { - c.x = nx; - c.y = ny; - moved = true; + let pressEdge: boolean; + let releaseEdge: boolean; + if (c.source === "pointer") { + const p = pointer(); + if (p) { + const nx = Math.min(Math.max(p.x, 0), c.vw - 1); + const ny = Math.min(Math.max(p.y, 0), c.vh - 1); + if (nx !== c.x || ny !== c.y) { + c.x = nx; + c.y = ny; + moved = true; + } + } + // A frame with no pointer (it left the window) holds the last position + // and reports no edge, so an in-flight press stays armed until the + // pointer comes back and actually releases. + const down = p ? p.down : c.prevDown; + pressEdge = down && !c.prevDown; + releaseEdge = !down && c.prevDown; + c.prevDown = down; + } else { + let vx = analogX() * c.speed; + let vy = analogY() * c.speed; + if (c.dpadSpeed > 0 && vx === 0 && vy === 0) { + if (buttons & BTN.LEFT) vx = -c.dpadSpeed; + if (buttons & BTN.RIGHT) vx = c.dpadSpeed; + if (buttons & BTN.UP) vy = -c.dpadSpeed; + if (buttons & BTN.DOWN) vy = c.dpadSpeed; + } + if (vx !== 0 || vy !== 0) { + const dt = ticksPerFrame() / 60; + const nx = Math.min(Math.max(c.x + vx * dt, 0), c.vw - 1); + const ny = Math.min(Math.max(c.y + vy * dt, 0), c.vh - 1); + if (nx !== c.x || ny !== c.y) { + c.x = nx; + c.y = ny; + moved = true; + } } + pressEdge = (pressed & c.button) !== 0; + releaseEdge = (released & c.button) !== 0; } if (moved) ops.setCursorPos(c.x, c.y); @@ -715,9 +781,9 @@ function cursorFrame(buttons: number, pressed: number, released: number): boolea // edged (clicks always resolve against live geometry), or inputGen // ticked (tree/style/focusable/scope changes). A parked cursor over a // quiet tree costs nothing per frame. ----------------------------------- - const edges = (pressed | released) & c.button; + const edges = pressEdge || releaseEdge; const gen = inputGen; - if (moved || edges !== 0 || gen !== c.gen) { + if (moved || edges || gen !== c.gen) { c.gen = gen; c.fresh = false; c.target = cursorTarget(findMirror(hitRoot ?? root, ops.hitTest(c.x, c.y))); @@ -725,21 +791,21 @@ function cursorFrame(buttons: number, pressed: number, released: number): boolea const target = c.target; if (target !== focused) focusNode(target); - // -- press/click on the configured button --------------------------------- - if (pressed & c.button && target) { + // -- press/click on the configured button (or the pointer's own) ---------- + if (pressEdge && target) { c.pressTarget = target; } if (c.pressTarget) { // Held: pressed visuals only while still over the armed node (leave to // pop back up, re-enter to re-press). setPressedNode(target === c.pressTarget ? c.pressTarget : null); - if (released & c.button) { + if (releaseEdge) { const fire = target === c.pressTarget; c.pressTarget = null; setPressedNode(null); if (fire) firePress(); } - } else if (released & c.button) { + } else if (releaseEdge) { // A press that predates the cursor (classic-mode latch, or a press held // across enableCursor) still releases its `active:` visual. setPressedNode(null); diff --git a/framework/src/pointer.ts b/framework/src/pointer.ts new file mode 100644 index 00000000..1378225e --- /dev/null +++ b/framework/src/pointer.ts @@ -0,0 +1,62 @@ +// Target-neutral pointer snapshot delivered at the start of each host frame +// (input.pointer). Coordinates are logical PocketJS pixels, so applications +// never compensate for a window's raster density or scale factor. +// +// This is the REAL absolute pointer of a mouse/trackpad host, distinct from +// input.cursor's nub-synthesized cursor: it reports hover (motion with no +// button held), which a finger on a panel cannot produce and a nub-steered +// cursor only approximates. Hosts without one simply never pass the argument, +// and every existing host, tape and golden is unchanged. + +export interface PointerSnapshot { + /** Logical viewport X coordinate. */ + readonly x: number; + /** Logical viewport Y coordinate. */ + readonly y: number; + /** Whether the primary button is held this frame. */ + readonly down: boolean; +} + +const COORD_BITS = 10; +const COORD_MASK = (1 << COORD_BITS) - 1; +const DOWN_BIT = 1 << (COORD_BITS * 2); + +let snapshot: PointerSnapshot | null = null; + +/** + * Internal host-frame hook. + * + * Packed as `(down << 20) | (y << 10) | x`, 10 bits per axis (logical + * coordinates up to 1023). `undefined` means the host has no pointer this + * frame — the cursor holds its last position rather than jumping to an + * origin, which is what a pointer leaving the window should look like. + */ +export function __setPointer(packed: number | undefined): void { + if (packed === undefined) { + snapshot = null; + return; + } + snapshot = Object.freeze({ + x: packed & COORD_MASK, + y: (packed >>> COORD_BITS) & COORD_MASK, + down: (packed & DOWN_BIT) !== 0, + }); +} + +/** The host pointer for the current frame, or null when the host has none. */ +export function pointer(): PointerSnapshot | null { + return snapshot; +} + +export function __resetPointer(): void { + snapshot = null; +} + +/** Test/native helper matching the native frame wire format. */ +export function __packPointer(x: number, y: number, down: boolean): number { + return ( + ((down ? 1 : 0) << (COORD_BITS * 2)) | + ((y & COORD_MASK) << COORD_BITS) | + (x & COORD_MASK) + ) >>> 0; +} diff --git a/package.json b/package.json index 45b60db0..49016acd 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,7 @@ "e2e:launcher": "bun tests/e2e/launcher-ppsspp.ts", "e2e:launcher:vita": "bun tests/e2e/launcher-vita3k.ts", "pocket:pack": "bun tools/pocket-pack.ts", - "test": "bun tools/build.ts hero >/dev/null && bun tests/contract.ts && bun test tests/release-check.test.ts tests/release-notes.test.ts tests/platform-contracts.test.ts tests/pocket-package.test.ts tests/widget-args.test.ts tests/ipod-nano.test.ts tests/note.test.ts tests/site-stage.test.ts tests/host-build-inputs.test.ts tests/platform-runtime.test.ts tests/app-check.test.ts tests/vue-sfc.test.ts tests/font-bake.test.ts tests/touch.test.ts tests/vita-package.test.ts tests/psp-toolchain.test.ts tests/symbian-data.test.ts tests/symbian-toolchain.test.ts tests/symbian-device.test.ts tests/symbian-runtime.test.ts tests/cli.test.ts tests/npm-package.test.ts tests/video-outro.test.ts tests/osk-layout.test.ts && bun test --conditions=browser tests/tailwind.test.ts tests/renderer.test.ts tests/cursor.test.ts tests/action-handler-vue-vapor.test.ts tests/vue-vapor-dom.test.ts tests/vue-vapor-pak.test.ts tests/svg-bake.test.ts tests/devtools.test.ts tests/hot.test.ts tests/clock.test.ts tests/tiles.test.ts && bun tools/build.ts hero-vue-sfc-main --framework=vue-vapor >/dev/null && bun tools/build.ts vue-sfc-lab-main --framework=vue-vapor >/dev/null && bun test --conditions=browser tests/vue-sfc-lab.test.ts && bun tools/build.ts cafe-main >/dev/null && bun test --conditions=browser tests/sim.test.ts && bun tools/build.ts zoomlab-main >/dev/null && bun test --conditions=browser tests/deepzoom-sim.test.ts && bun tools/build.ts im-main >/dev/null && bun test --conditions=browser tests/im-sim.test.ts && bun tools/launcher.ts covers >/dev/null && bun test --conditions=browser tests/launcher-sim.test.ts", + "test": "bun tools/build.ts hero >/dev/null && bun tests/contract.ts && bun test tests/release-check.test.ts tests/release-notes.test.ts tests/platform-contracts.test.ts tests/pocket-package.test.ts tests/widget-args.test.ts tests/ipod-nano.test.ts tests/note.test.ts tests/site-stage.test.ts tests/host-build-inputs.test.ts tests/platform-runtime.test.ts tests/app-check.test.ts tests/vue-sfc.test.ts tests/font-bake.test.ts tests/touch.test.ts tests/pointer.test.ts tests/vita-package.test.ts tests/psp-toolchain.test.ts tests/symbian-data.test.ts tests/symbian-toolchain.test.ts tests/symbian-device.test.ts tests/symbian-runtime.test.ts tests/cli.test.ts tests/npm-package.test.ts tests/video-outro.test.ts tests/osk-layout.test.ts && bun test --conditions=browser tests/tailwind.test.ts tests/renderer.test.ts tests/cursor.test.ts tests/action-handler-vue-vapor.test.ts tests/vue-vapor-dom.test.ts tests/vue-vapor-pak.test.ts tests/svg-bake.test.ts tests/devtools.test.ts tests/hot.test.ts tests/clock.test.ts tests/tiles.test.ts && bun tools/build.ts hero-vue-sfc-main --framework=vue-vapor >/dev/null && bun tools/build.ts vue-sfc-lab-main --framework=vue-vapor >/dev/null && bun test --conditions=browser tests/vue-sfc-lab.test.ts && bun tools/build.ts cafe-main >/dev/null && bun test --conditions=browser tests/sim.test.ts && bun tools/build.ts zoomlab-main >/dev/null && bun test --conditions=browser tests/deepzoom-sim.test.ts && bun tools/build.ts im-main >/dev/null && bun test --conditions=browser tests/im-sim.test.ts && bun tools/launcher.ts covers >/dev/null && bun test --conditions=browser tests/launcher-sim.test.ts", "tape": "bun tools/tape.ts", "tape:check": "bun tools/tape.ts replay hero-main tests/tapes/hero-main.tape.json --assert tests/tapes/hero-main.hashes.json", "devtools": "bun tools/devtools.ts", diff --git a/tests/cursor.test.ts b/tests/cursor.test.ts index cecee017..a9a5b5d8 100644 --- a/tests/cursor.test.ts +++ b/tests/cursor.test.ts @@ -21,6 +21,7 @@ import { setInputRoot, } from "../framework/src/input.ts"; import { __setAnalog } from "../framework/src/frame.ts"; +import { __packPointer, __resetPointer, __setPointer } from "../framework/src/pointer.ts"; import { resetClock } from "../framework/src/clock.ts"; import type { NodeMirror } from "../framework/src/renderer.ts"; import { ANALOG_CENTER, BTN, NODE_TYPE, PSM, ROOT_ID, SCREEN_H, SCREEN_W } from "../contracts/spec/spec.ts"; @@ -117,6 +118,7 @@ beforeEach(() => { resetInput(); resetClock(); __setAnalog(ANALOG_CENTER); + __resetPointer(); root = mk(ROOT_ID, null); setInputRoot(root); }); @@ -351,3 +353,127 @@ describe("re-enable", () => { expect(host.of("setActive").pop()).toEqual(["setActive", row.id, 0]); }); }); + +// input.pointer: a REAL absolute pointer drives the cursor, and its own button +// supplies the press edges. Nub deflection must not move it, and the click has +// to land on whatever the pointer is over — the guarantee input.cursor cannot +// make. +describe("host pointer source", () => { + /** Deliver one host frame carrying an absolute pointer. */ + function point(x: number, y: number, down: boolean, buttons = 0): void { + __setPointer(__packPointer(x, y, down)); + handleFrame(buttons); + } + + test("the cursor adopts the host position instead of integrating the nub", () => { + enableCursor({ source: "pointer" }); + point(0, 0, false); // first frame: latch viewport + sprite + host.clear(); + point(140, 96, false); + expect(host.of("setCursorPos")).toEqual([["setCursorPos", 140, 96]]); + expect([cursorX(), cursorY()]).toEqual([140, 96]); + + // A fully deflected nub must not budge a host-driven pointer. + host.clear(); + __setAnalog(0x00ff); + point(140, 96, false); + expect(host.of("setCursorPos")).toEqual([]); + expect([cursorX(), cursorY()]).toEqual([140, 96]); + }); + + test("hover focuses what is under the pointer with no button held", () => { + const row = mk(11, root, { focusable: true }); + enableCursor({ source: "pointer" }); + host.hitResult = row.id; + point(40, 40, false); + expect(getFocused()).toBe(row); + // Hover alone must not look pressed. + expect(host.of("setActive")).toEqual([]); + }); + + test("press, hold and release over a node fire its onPress once", () => { + let fired = 0; + const row = mk(12, root, { focusable: true, onPress: () => fired++ }); + enableCursor({ source: "pointer" }); + host.hitResult = row.id; + point(40, 40, false); + host.clear(); + + point(40, 40, true); // press edge arms + shows active + expect(host.of("setActive").pop()).toEqual(["setActive", row.id, 1]); + expect(fired).toBe(0); + + point(42, 41, true); // dragging inside the node keeps it armed + expect(fired).toBe(0); + + point(42, 41, false); // release over the armed node fires + expect(fired).toBe(1); + expect(host.of("setActive").pop()).toEqual(["setActive", row.id, 0]); + + // The button staying up must not re-fire. + point(42, 41, false); + expect(fired).toBe(1); + }); + + test("releasing away from the armed node cancels the click", () => { + let fired = 0; + const row = mk(13, root, { focusable: true, onPress: () => fired++ }); + const other = mk(14, root, { focusable: true }); + enableCursor({ source: "pointer" }); + host.hitResult = row.id; + point(10, 10, false); + point(10, 10, true); // armed on row + + host.hitResult = other.id; // pointer slid onto a different node + point(90, 90, true); + expect(host.of("setActive").pop()).toEqual(["setActive", row.id, 0]); + point(90, 90, false); + expect(fired).toBe(0); + }); + + test("the PSP button mask does not click a host-driven cursor", () => { + let fired = 0; + const row = mk(15, root, { focusable: true, onPress: () => fired++ }); + enableCursor({ source: "pointer" }); + host.hitResult = row.id; + point(10, 10, false); + // CIRCLE in the mask is the classic model's click; a real pointer carries + // its own button, so the mask must not synthesize a second one. + point(10, 10, false, BTN.CIRCLE); + point(10, 10, false, 0); + expect(fired).toBe(0); + }); + + test("a frame with no pointer holds position and keeps a press armed", () => { + let fired = 0; + const row = mk(16, root, { focusable: true, onPress: () => fired++ }); + enableCursor({ source: "pointer" }); + host.hitResult = row.id; + point(30, 30, false); + point(30, 30, true); // armed + + __setPointer(undefined); // pointer left the window mid-press + handleFrame(0); + expect([cursorX(), cursorY()]).toEqual([30, 30]); + expect(fired).toBe(0); + + point(30, 30, false); // it came back and released + expect(fired).toBe(1); + }); + + test("out-of-range coordinates clamp into the viewport", () => { + enableCursor({ source: "pointer" }); + point(0, 0, false); + point(SCREEN_W + 500, SCREEN_H + 500, false); + expect([cursorX(), cursorY()]).toEqual([SCREEN_W - 1, SCREEN_H - 1]); + }); + + test("the default source is still the analog nub", () => { + enableCursor(); + handleFrame(0); + host.clear(); + __setAnalog(0x00ff); // full deflection steers the classic cursor + handleFrame(0); + expect(host.of("setCursorPos").length).toBe(1); + }); +}); diff --git a/tests/pointer.test.ts b/tests/pointer.test.ts new file mode 100644 index 00000000..ae461b9b --- /dev/null +++ b/tests/pointer.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + __packPointer, + __resetPointer, + __setPointer, + pointer, +} from "../framework/src/pointer.ts"; + +afterEach(__resetPointer); + +describe("pointer frame snapshot", () => { + test("decodes logical coordinates and the button state", () => { + __setPointer(__packPointer(12, 34, false)); + expect(pointer()).toEqual({ x: 12, y: 34, down: false }); + + __setPointer(__packPointer(479, 271, true)); + expect(pointer()).toEqual({ x: 479, y: 271, down: true }); + }); + + test("carries coordinates past the touch channel's 9-bit range", () => { + __setPointer(__packPointer(1023, 1000, true)); + expect(pointer()).toEqual({ x: 1023, y: 1000, down: true }); + }); + + test("publishes an immutable per-frame snapshot", () => { + __setPointer(__packPointer(20, 40, true)); + const first = pointer(); + expect(Object.isFrozen(first)).toBe(true); + __setPointer(__packPointer(99, 99, false)); + expect(first).toEqual({ x: 20, y: 40, down: true }); + }); + + test("a host with no pointer this frame reports null, not an origin", () => { + __setPointer(__packPointer(5, 6, true)); + __setPointer(undefined); + // Null is distinguishable from (0,0): the cursor holds its last position + // instead of snapping to a corner when the pointer leaves the window. + expect(pointer()).toBeNull(); + }); + + test("hosts that never pass a pointer see none", () => { + expect(pointer()).toBeNull(); + }); +}); From dd2d1724bbe5add9cc911e97f021d04d3f600812 Mon Sep 17 00:00:00 2001 From: Chengyu Wu <7086cmd@gmail.com> Date: Thu, 6 Aug 2026 10:21:39 +0800 Subject: [PATCH 2/2] feat(hero): hover-focus the node under a contact so pointers can click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hero marks its button `focusable` with `onPress` but never establishes initial focus, and the framework deliberately does not auto-focus. `firePress` walks up from `focused`, which is null until a d-pad press, so CIRCLE on a freshly mounted hero activates nothing — and a host that reports an absolute point (a Vita/PocketBook panel, or a desktop host presenting a mouse as a contact) had no way to reach the button at all. Resolve the first contact to the focusable under it in onFrame, which runs before input edge detection, so a press arriving in the same frame lands on the node just focused rather than on whatever the d-pad left focused. This is the same hover-IS-focus pattern apps/note already uses for its svc mouse stream, spelled against the portable touch channel instead. Hosts with neither touch nor a pointer report no contact and this is a no-op, so the d-pad remains the portable default and the deterministic tape — which scrubs touch on replay — still matches all 180 frames. --- apps/hero/app.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/hero/app.tsx b/apps/hero/app.tsx index 1f064539..9fe2107f 100644 --- a/apps/hero/app.tsx +++ b/apps/hero/app.tsx @@ -5,7 +5,8 @@ import { createSignal, onMount, Show } from "solid-js"; import { Image, Text, View, type NodeMirror } from "@pocketjs/framework/components"; import { animate } from "@pocketjs/framework/animation"; -import { createSpriteAnimation } from "@pocketjs/framework/lifecycle"; +import { createSpriteAnimation, onFrame } from "@pocketjs/framework/lifecycle"; +import { focusNode, hitFocusable, touches } from "@pocketjs/framework/input"; import { frameworkName } from "@pocketjs/framework"; const SPINNER_FRAME_STEP = 3; @@ -37,6 +38,25 @@ export default function Hero() { // Underline sweeps in once on mount — native tween, zero steady-state JS. if (underline) animate(underline, "width", 210, { dur: 700, easing: "out", delay: 150 }); }); + + // Hover IS focus: a touch host (Vita, PocketBook) and a host that presents a + // mouse as a contact both report an absolute point, so resolve it to the + // focusable under it. onFrame runs BEFORE input edge detection, so the press + // that arrives in this same frame lands on the node just focused here rather + // than on whatever the d-pad left focused. Hosts with neither report no + // contact and this is a no-op — the d-pad stays the portable default. + let hovered: NodeMirror | null = null; + onFrame(() => { + const [contact] = touches(); + if (!contact) return; + // A miss must clear focus, not leave it where it was: `handleFrame` fires + // onPress on whatever is focused, so keeping the last hit would make a + // press on empty space activate the button the pointer had merely passed + // over. Pass null through, exactly as cursorFrame does for the nub cursor. + const node = hitFocusable(contact.x, contact.y); + if (node !== hovered) focusNode(node); + hovered = node; + }); return (