Skip to content
Open
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
6 changes: 6 additions & 0 deletions apps/launcher/images.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@
"covers/refl-im-main.png": {
"linear": true
},
"covers/cover-touch-lab-main.png": {
"linear": true
},
"covers/refl-touch-lab-main.png": {
"linear": true
},
"covers/cover-vue-sfc-lab-main.png": {
"linear": true
},
Expand Down
1 change: 1 addition & 0 deletions apps/launcher/registry.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const REGISTRY: readonly RegistryApp[] = [
{ output: "music-main", id: "dev.pocket-stack.music", title: "PocketJS: Now Playing", cover: "covers/cover-music-main.png", refl: "covers/refl-music-main.png" },
{ output: "settings-main", id: "dev.pocket-stack.settings", title: "PocketJS: Settings", cover: "covers/cover-settings-main.png", refl: "covers/refl-settings-main.png" },
{ output: "im-main", id: "dev.pocket-stack.im", title: "PocketJS: Talk", cover: "covers/cover-im-main.png", refl: "covers/refl-im-main.png" },
{ output: "touch-lab-main", id: "dev.pocket-stack.touch.lab", title: "PocketJS: Touch Events Lab", cover: "covers/cover-touch-lab-main.png", refl: "covers/refl-touch-lab-main.png" },
{ output: "vue-sfc-lab-main", id: "dev.pocket-stack.vue.sfc.lab", title: "PocketJS: Vue SFC Feature Lab", cover: "covers/cover-vue-sfc-lab-main.png", refl: "covers/refl-vue-sfc-lab-main.png" },
{ output: "zoomlab-main", id: "dev.pocket-stack.zoomlab", title: "Zoom Lab", cover: "covers/cover-zoomlab-main.png", refl: "covers/refl-zoomlab-main.png" },
] as const;
108 changes: 108 additions & 0 deletions apps/touch-lab/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<script setup lang="ts">
// Touch event smoke test: three pads exercising start/move/end/cancel,
// implicit capture, and bubbling — click/drag on them in the web host.
import { ref } from "vue";
import { Text, View } from "@pocketjs/framework/vue-vapor/components";
import type { PocketTouchEvent } from "@pocketjs/framework/vue-vapor/input";

const log = ref<string[]>([]);
const tapCount = ref(0);
const dragPos = ref("—");
const bubbleOrder = ref("—");

function push(msg: string): void {
log.value = [msg, ...log.value].slice(0, 6);
(globalThis as any).__touchLabLog = log.value;
(globalThis as any).__touchLab = { taps: tapCount.value, drag: dragPos.value, bubble: bubbleOrder.value };
}

let downX = 0;
let downY = 0;

function onStart(ev: PocketTouchEvent): void {
const t = ev.changedTouches[0];
downX = t.clientX;
downY = t.clientY;
push(`start ${t.clientX},${t.clientY} ts=${ev.timeStamp.toFixed(0)}`);
}

function onEnd(ev: PocketTouchEvent): void {
const t = ev.changedTouches[0];
push(`end ${t.clientX},${t.clientY} touches=${ev.touches.length}`);
const dx = t.clientX - downX;
const dy = t.clientY - downY;
if (dx * dx + dy * dy <= 64) tapCount.value++;
}

function onMove(ev: PocketTouchEvent): void {
const t = ev.changedTouches[0];
dragPos.value = `${t.clientX},${t.clientY}`;
(globalThis as any).__touchLab = { taps: tapCount.value, drag: dragPos.value, bubble: bubbleOrder.value };
}

// Bubble probe: reset on every press; child claims the press via
// stopPropagation so parent only records presses that landed on itself.
let childClaimed = false;
function syncLab(): void {
(globalThis as any).__touchLab = { taps: tapCount.value, drag: dragPos.value, bubble: bubbleOrder.value };
}
function onBubbleAreaStart(): void {
childClaimed = false;
bubbleOrder.value = "none";
syncLab();
}
function onBubbleChild(ev: PocketTouchEvent): void {
childClaimed = true;
bubbleOrder.value = "child";
syncLab();
ev.stopPropagation();
}
function onBubbleParent(): void {
if (!childClaimed) {
bubbleOrder.value = "parent";
syncLab();
}
}
</script>

<template>
<View class="w-full h-full flex-col gap-2 bg-slate-950 p-2" @touchstart="onBubbleAreaStart">
<Text class="text-xs text-slate-400">touch-events smoke: taps={{ tapCount }} drag={{ dragPos }} bubble={{ bubbleOrder }}</Text>

<View class="flex-row gap-2">
<View
class="w-24 h-16 rounded-lg bg-blue-700 items-center justify-center"
@touchstart="onStart"
@touchend="onEnd"
@touchcancel="() => push('cancel')"
>
<Text class="text-xs text-white">TAP PAD</Text>
</View>

<View
class="w-24 h-16 rounded-lg bg-emerald-700 items-center justify-center"
@touchstart="onStart"
@touchmove="onMove"
@touchend="onEnd"
>
<Text class="text-xs text-white">DRAG PAD</Text>
</View>

<View
class="w-24 h-16 rounded-lg bg-rose-800 items-center justify-center"
@touchstart="onBubbleParent"
>
<View
class="w-16 h-10 rounded bg-rose-500 items-center justify-center"
@touchstart="onBubbleChild"
>
<Text class="text-xs text-white">CHILD</Text>
</View>
</View>
</View>

<View class="flex-col">
<Text v-for="(line, i) in log" :key="i" class="text-xs text-slate-300">{{ line }}</Text>
</View>
</View>
</template>
4 changes: 4 additions & 0 deletions apps/touch-lab/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { mount } from "@pocketjs/framework/vue-vapor";
import TouchLab from "./app.vue";

mount(TouchLab);
24 changes: 24 additions & 0 deletions apps/touch-lab/pocket.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"$schema": "https://pocketjs.dev/schema/pocket-2.json",
"pocket": 2,
"id": "dev.pocket-stack.touch.lab",
"name": "pocketjs-touch-lab",
"title": "PocketJS: Touch Events Lab",
"version": "0.6.0",
"engine": {
"capabilities": {
"requires": ["text.glyphs.baked"]
}
},
"app": {
"entry": "apps/touch-lab/main.ts",
"output": "touch-lab-main",
"framework": "vue-vapor",
"viewport": {
"fixed": {
"logical": [480, 272],
"presentation": "integer-fit"
}
}
}
}
44 changes: 38 additions & 6 deletions framework/src/components-vue-vapor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
type NodeMirror,
} from "./native-tree.ts";
import { createRenderRoot, type RenderRoot } from "./renderer-vue-vapor.ts";
import type { TouchHandler } from "./touch-events.ts";
import type { PocketTouchEvent } from "./input-api.ts";

export type { NodeMirror } from "./renderer-vue-vapor.ts";

Expand All @@ -33,6 +35,16 @@ type NodeRef = ((node: NodeMirror | null) => void) | { current: NodeMirror | nul
type SlotFn = (...args: unknown[]) => unknown;
type SlotBag = Record<string, SlotFn | undefined> | SlotFn | undefined;

/** W3C-subset touch handlers shared by every primitive (framework/src/touch-events.ts).
* Vue templates bind them as @touchstart / @touchmove / @touchend / @touchcancel;
* JSX spells them onTouchstart etc. Both land in native-tree's touch registry. */
export interface TouchHandlers {
onTouchstart?: (ev: PocketTouchEvent) => void;
onTouchmove?: (ev: PocketTouchEvent) => void;
onTouchend?: (ev: PocketTouchEvent) => void;
onTouchcancel?: (ev: PocketTouchEvent) => void;
}

export type VNodeChild = SolidJSX.Element | (() => SolidJSX.Element);
const NO_FALLTHROUGH = { inheritAttrs: false } as const;
type VaporCtx = { slots: SlotBag; attrs: Record<string, unknown> };
Expand All @@ -46,7 +58,7 @@ const insertVaporBlock = vaporInsert as unknown as (
parent: NodeMirror,
anchor?: NodeMirror | null,
) => void;
export interface ViewProps {
export interface ViewProps extends TouchHandlers {
class?: string;
className?: string;
style?: StyleObject;
Expand All @@ -56,23 +68,23 @@ export interface ViewProps {
children?: VNodeChild;
}

export interface TextProps {
export interface TextProps extends TouchHandlers {
class?: string;
className?: string;
style?: StyleObject;
nodeRef?: NodeRef;
children?: VNodeChild;
}

export interface ImageProps {
export interface ImageProps extends TouchHandlers {
class?: string;
className?: string;
src?: string;
style?: StyleObject;
nodeRef?: NodeRef;
}

export interface SpriteProps {
export interface SpriteProps extends TouchHandlers {
class?: string;
className?: string;
sprite?: string;
Expand Down Expand Up @@ -206,6 +218,15 @@ function cleanProps(props: Record<string, unknown>, omit: Set<string>): HostProp
if (key === "class" || key === "className") out[key] = normalizeClassValue(rawValue);
else if (key === "style") out[key] = normalizeStyleValue(rawValue);
else if (key === "onPress" || key === "on:press") out[key] = callbackOf<() => void>(rawValue);
// Touch handlers are event callbacks, not value getters: wrap with
// callbackOf so Vue's invoke-for-value semantics never call them with
// zero arguments (which would crash on ev.changedTouches).
else if (
key === "onTouchstart" || key === "on:touchstart" ||
key === "onTouchmove" || key === "on:touchmove" ||
key === "onTouchend" || key === "on:touchend" ||
key === "onTouchcancel" || key === "on:touchcancel"
) out[key] = callbackOf<TouchHandler>(rawValue);
// Primitive host components intentionally keep props in attrs instead of
// declaring runtime prop tables. Vue therefore leaves a bare boolean
// attribute as ""; on native components it still means true.
Expand Down Expand Up @@ -265,17 +286,28 @@ function createPrimitiveNode(
return node;
}

function primitive(tag: "view"): (props: ViewProps) => SolidJSX.Element;
function primitive(tag: "text"): (props: TextProps) => SolidJSX.Element;
function primitive(tag: "image"): (props: ImageProps) => SolidJSX.Element;
function primitive(tag: "view" | "text" | "image") {
// The implementation reads attrs (untyped fallthrough bag); the overloads
// above pin each tag's PUBLIC props contract. Runtime behavior is identical
// for all three tags — only the type surface differs.
return definePocketVaporComponent(
(_props: Record<string, unknown>, { attrs, slots }: VaporCtx) => createPrimitiveNode(tag, attrs, slots),
(_props: ViewProps | TextProps | ImageProps, { attrs, slots }: VaporCtx) =>
createPrimitiveNode(tag, attrs, slots),
NO_FALLTHROUGH,
);
}

export const View = primitive("view");
export const Text = primitive("text");
export const Image = primitive("image");
export const Sprite = primitive("image");
export const Sprite = definePocketVaporComponent(
(_props: SpriteProps, { attrs, slots }: VaporCtx) =>
createPrimitiveNode("image", attrs, slots),
NO_FALLTHROUGH,
);

function resolveActive(active: unknown): boolean {
const resolved = valueOf(active);
Expand Down
10 changes: 6 additions & 4 deletions framework/src/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,9 @@ export function initDevtools(ops: HostOps): void {

/** Wrap the composed frame handler (render()'s input+hooks+sweep closure). */
export function wrapFrameHandler(
h: (buttons: number, analog: number, touches?: readonly number[]) => void,
): (buttons: number, analog?: number, touches?: readonly number[]) => void {
return (buttons: number, analogArg?: number, touchArg?: readonly number[]) => {
h: (buttons: number, analog: number, touches?: readonly number[], touchEvents?: readonly number[]) => void,
): (buttons: number, analog?: number, touches?: readonly number[], touchEvents?: readonly number[]) => void {
return (buttons: number, analogArg?: number, touchArg?: readonly number[], touchEventArg?: readonly number[]) => {
state.hostCalls++;
if (state.transport) {
pollTransport();
Expand All @@ -170,13 +170,15 @@ export function wrapFrameHandler(
let mask = buttons;
let analog = analogArg === undefined ? ANALOG_CENTER : analogArg & 0xffff;
let touch = touchArg;
let touchEvents = touchEventArg;
if (state.replayMasks) {
if (state.replayAt < state.replayMasks.length) {
mask = state.replayMasks[state.replayAt];
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.
touch = undefined;
touchEvents = undefined;
state.replayAt++;
} else {
state.replayMasks = null; // tape exhausted: back to live input
Expand All @@ -192,7 +194,7 @@ export function wrapFrameHandler(
recordMask(mask, analog);
state.frame++;
try {
h(mask, analog, touch);
h(mask, analog, touch, touchEvents);
} catch (e) {
send({
t: "error",
Expand Down
9 changes: 8 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 { handleTouchSamples, resetTouchEvents, setTouchHitRootProvider } from "./touch-events.ts";
import { __advanceClock, resetClock } from "./clock.ts";
import { __drainEffects, resetEffects } from "./effects.ts";
import { entries as pakEntries, get as pakGet, hasPack, loadPack } from "./pak.ts";
Expand Down Expand Up @@ -196,15 +197,19 @@ export function render(code: VaporRenderRoot, opts: RenderOptions = {}): () => v
overlayLayer = overlayRoot;

setInputRoot(appRoot);
setTouchHitRootProvider(() => rootMirror); // touch events see app + overlay
resetFrameHooks();
resetClock(); // clock policy + effect shell (docs/DETERMINISM.md), same as Solid
resetEffects();
initDevtools(host.ops); // DevTools shim (docs/DEVTOOLS.md), same as the Solid path.
installFrameHandler(
wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[]) => {
wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[], touchEvents?: readonly number[]) => {
__advanceClock();
__setAnalog(analog);
__setTouches(touches);
// ESP32 QuickJS delivers samples via __tev (its frame wire is 1-arg);
// every other host passes them as the 4th frame argument.
handleTouchSamples(touchEvents ?? (globalThis as { __tev?: readonly number[] }).__tev);
__drainEffects();
runFrameHooks(buttons);
handleFrame(buttons);
Expand All @@ -217,8 +222,10 @@ export function render(code: VaporRenderRoot, opts: RenderOptions = {}): () => v
return () => {
removeResizeViewportHook();
__resetTouches();
resetTouchEvents(); // drop capture table (native nodes die with the tree)
dispose();
setInputRoot(null);
setTouchHitRootProvider(null);
setOverlayRoot(null);
appLayer = null;
overlayLayer = null;
Expand Down
7 changes: 6 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 { handleTouchSamples, resetTouchEvents, setTouchHitRootProvider } from "./touch-events.ts";
import { __advanceClock, resetClock } from "./clock.ts";
import { __drainEffects, resetEffects } from "./effects.ts";
import { entries as pakEntries, get as pakGet, hasPack, loadPack } from "./pak.ts";
Expand Down Expand Up @@ -249,16 +250,18 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi

setInputRoot(appRoot);
setHitRoot(rootMirror); // hit tests see the overlay layer too
setTouchHitRootProvider(() => rootMirror); // touch events resolve against the same tree
resetFrameHooks();
resetClock(); // latches the host's __simHz clock policy (docs/DETERMINISM.md)
resetEffects();
initDevtools(host.ops); // DevTools shim (docs/DEVTOOLS.md): flight recorder +
// debug channel; one branch per frame when no transport is connected.
installFrameHandler(
wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[]) => {
wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[], touchEvents?: readonly number[]) => {
__advanceClock(); // virtual frame++, fire due after() timers
__setAnalog(analog); // latch the nub before any app code reads it
__setTouches(touches); // latch logical front-panel contacts for this frame
handleTouchSamples(touchEvents); // browser-aligned dispatch (W3C subset)
__drainEffects(); // frame-boundary deliveries enter the world first
runFrameHooks(buttons); // app lifecycle callbacks: onFrame/onButtonPress/etc.
handleFrame(buttons); // edge-detect, focus nav, onPress (runs effects)
Expand All @@ -271,9 +274,11 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi
return () => {
removeResizeViewportHook();
__resetTouches();
resetTouchEvents(); // drop capture table (native nodes die with the tree)
dispose(); // tears down reactivity only — universal keeps the nodes
setInputRoot(null); // drops focus state (native focus dies with the nodes)
setHitRoot(null);
setTouchHitRootProvider(null);
setOverlayRoot(null);
appLayer = null;
overlayLayer = null;
Expand Down
1 change: 1 addition & 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 type { PocketTouch, PocketTouchEvent, TouchEventType } from "./touch-events.ts";
export {
cursorX,
cursorY,
Expand Down
Loading