Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion apps/hero/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<View
debugName="HeroScreen"
Expand Down
64 changes: 64 additions & 0 deletions engine/crates/pocket-mod/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
) -> Result<()> {
self.ctx.with(|ctx| -> Result<()> {
let frame: Option<Function> = 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) {
Expand Down Expand Up @@ -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();
Expand Down
29 changes: 23 additions & 6 deletions framework/src/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand All @@ -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",
Expand Down
14 changes: 12 additions & 2 deletions framework/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
10 changes: 9 additions & 1 deletion framework/src/index-vue-vapor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -217,6 +224,7 @@ export function render(code: VaporRenderRoot, opts: RenderOptions = {}): () => v
return () => {
removeResizeViewportHook();
__resetTouches();
__resetPointer();
dispose();
setInputRoot(null);
setOverlayRoot(null);
Expand Down
10 changes: 9 additions & 1 deletion framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand All @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions framework/src/input-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -13,6 +14,7 @@ export {
pushFocusGrid,
pushFocusScope,
type CursorOptions,
type CursorSource,
type FocusDirection,
type FocusGridOptions,
type FocusScopeOptions,
Expand Down
Loading