Skip to content

feat(input): implement input.pointer as a cursor position source - #229

Closed
7086cmd wants to merge 2 commits into
pocket-stack:mainfrom
7086cmd:feat/input-pointer-cursor-source
Closed

feat(input): implement input.pointer as a cursor position source#229
7086cmd wants to merge 2 commits into
pocket-stack:mainfrom
7086cmd:feat/input-pointer-cursor-source

Conversation

@7086cmd

@7086cmd 7086cmd commented Aug 6, 2026

Copy link
Copy Markdown

Why

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 not input.cursor (contracts/spec/platforms.ts, docs/WIDGET.md). 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:

// Clicks are CIRCLE — hover already focused what's under the pointer.
let buttons = if mouse_down { BTN_CIRCLE } else { 0 };

So every host with a real pointer reinvents hover and hit-resolution in app code. This gives the capability a framework implementation.

What

enableCursor({ source: "pointer" })

The host's absolute position drives the existing cursor state machine and the pointer's own button supplies the press/release edges. 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.

Default stays "analog", so every existing app, host, tape and golden is untouched.

Position arrives 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 — 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.

pocket-mod gains Guest::frame_with_pointer for native hosts.

Why a frame argument, not 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. Replay scrubs the pointer exactly like touch.

Second commit: apps/hero

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 reporting an absolute point (Vita/PocketBook panel, or a desktop host presenting a mouse as a contact) could not reach the button at all.

It now resolves the first contact to the focusable under it in onFrame, which runs before input edge detection — the same hover-IS-focus pattern apps/note already uses, spelled against the portable touch channel. Hosts with neither touch nor a pointer report no contact and it is a no-op.

Verification

  • bunx tsc --noEmit clean
  • bun run tape:check180 frames still match, both commits
  • 7 new cursor tests for the pointer source: position adoption vs. nub steering, hover-focus with no button held, 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 keeping a press armed, viewport clamping
  • 5 new pointer.ts snapshot tests, registered in package.json's test script
  • pocket-mod round-trip test for frame_with_pointer
  • Full bun run test: 242 pass, 1 fail — psp-toolchain.test.ts > doctor treats an invalid explicit LLVM override, which imports only tools/psp-toolchain.ts, has zero references to framework/ or pocket-mod, and fails on a machine with no PSP SDK. Please confirm it's green in your CI.

Context

Found while wiring mouse input for a consortium-hmi-pocket kiosk host (embedded Linux, where there is no OS cursor to fall back on). Happy to adjust the packing, the option name, or split the hero commit out.

🤖 Generated with Claude Code

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.
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.
@7086cmd
7086cmd force-pushed the feat/input-pointer-cursor-source branch from 22c0499 to dd2d172 Compare August 6, 2026 08:31
@doodlewind

Copy link
Copy Markdown
Collaborator

Thanks for working on this. The underlying goal is valid: give a real mouse or trackpad pointer a framework-owned path for absolute position, hover-to-focus, press, drag, release, and onPress, instead of making each host send private svc mouse messages and synthesize CIRCLE.

I am closing this PR because the implementation is based on an input model that current main has superseded, and carrying it forward would lock in the wrong boundaries:

  • frame argument 4 now carries touch hit facts, so the proposed pointer frame ABI conflicts with current main;
  • pointer input is scrubbed during replay but is not actually recorded or restored by the tape;
  • the 10-bit coordinates contradict the macos-widget 4096 by 4096 dynamic viewport, and the cached cursor viewport does not follow live resize;
  • a single down level cannot preserve a fast press-and-release in one tick or express leave, blur, and cancellation safely;
  • input.pointer is coupled to the virtual input.cursor sprite operations;
  • no stock host uses the new path, so there is no end-to-end host receipt;
  • the Hero change consumes touch rather than pointer and is superseded by the framework-level touch activation now on main.

This is a close-and-rework decision, not a rejection of mouse support. Please coordinate the next pointer implementation in #59 and start from current main. A focused replacement should define one versioned frame-input contract, explicit pointer edge and cancellation semantics, tape recording and replay, separation between real-pointer interaction and virtual-cursor rendering, and one real host integration such as note-widget or Linux SDL. It should also cover Solid, Vue Vapor, and Octane, plus resize, high-resolution, fast-click, cancellation, and replay tests.

Thanks again for pushing this direction forward.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants