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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ validation level are tracked in the [changelog](https://pocketjs.dev/changelog/)
| Build a Guest application | [Getting started](https://pocketjs.dev/docs/getting-started/) |
| Compare Solid, Vue Vapor, Vue SFC, and Octane | [Frameworks](https://pocketjs.dev/docs/frameworks/) |
| Compile for machines without a JS engine | [Pocket Vapor](./vapor/README.md) |
| Add or embed a native host | [Native contract](https://pocketjs.dev/docs/native-contract/) · [Platform contracts](https://pocketjs.dev/docs/platform-contracts/) |
| Add or embed a native host | [Native contract](https://pocketjs.dev/docs/native-contract/) · [Platform contracts](https://pocketjs.dev/docs/platform-contracts/) · [Pointer input](./docs/POINTER.md) |
| Build a game or specialized runtime | [Runtime family](./docs/RUNTIMES.md) · [Pocket3D](./engine/pocket3d/README.md) |
| Debug, replay, and verify | [DevTools](./docs/DEVTOOLS.md) · [Determinism](./docs/DETERMINISM.md) |
| Browse complete examples | [`apps/`](./apps/) · [PocketJS blog](https://pocketjs.dev/blog/) |
Expand Down
71 changes: 30 additions & 41 deletions apps/note/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@
// scroll — the IM thread contract: an untransformed overflow-hidden clip
// around a translateY canvas that mounts only the visible slice. Edit mode
// soft-wraps the raw source (editor.ts) with a real caret, drag selection
// and an undo/redo stack. The desktop host feeds keys/mouse/resizes through
// the svc channel (svc.ts) and synthesizes CIRCLE for clicks, so
// hover-focus + the stock onPress pipeline dispatch the chrome (toggle,
// menu) while content pointer gestures (caret, drag-select) ride the svc
// mouse stream directly; on hosts without svc (PSP, sim, goldens) the app
// and an undo/redo stack. The desktop host feeds keys/resizes through svc;
// its real mouse uses the framework's versioned pointer frame input. The
// framework owns hover-focus + onPress while this app consumes the same
// ordered edge batch for content caret/drag selection. On hosts without svc
// (PSP, sim, goldens) the app
// is a read-only note scrolled by d-pad — unmodified-app base case.

import { createMemo, createSignal, For, Show } from "solid-js";
import { Focusable, Image, Portal, Text, View } from "@pocketjs/framework/components";
import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle";
import { BTN, focusNode, hitFocusable } from "@pocketjs/framework/input";
import { resizeViewport, type NodeMirror } from "@pocketjs/framework";
import { BTN, pointerEvents } from "@pocketjs/framework/input";
import { resizeViewport } from "@pocketjs/framework";
import { hasFeature } from "@pocketjs/framework/platform";
import { parseMarkdown } from "./markdown.ts";
import {
Expand Down Expand Up @@ -157,7 +157,6 @@ export default function Note(): ReturnType<typeof View> {
const [preedit, setPreedit] = createSignal<{ text: string; cursor: number } | null>(null);
const [scrollV, setScrollV] = createSignal(0);
const [scrollE, setScrollE] = createSignal(0);
const [mouse, setMouse] = createSignal({ x: -1, y: -1 });

const ink = () => (dark() ? INK.dark : INK.light);
const contentW = () => Math.min(vp().w - PAD_X * 2, MAX_CONTENT_W);
Expand Down Expand Up @@ -232,9 +231,6 @@ export default function Note(): ReturnType<typeof View> {
let goalX = 0;
let goalSticky = false;
let saveIn = -1;
let lastHover: NodeMirror | null = null;
/** Re-run hover→focus next frame (a mode switch remounted the target). */
let rehover = false;

const markDirty = () => {
saveIn = SAVE_DEBOUNCE;
Expand Down Expand Up @@ -302,14 +298,12 @@ export default function Note(): ReturnType<typeof View> {
setScrollE(Math.max(0, Math.min(maxScrollE(), y - viewH() / 3)));
setEditing(true);
goalSticky = false;
rehover = true;
};
const leaveEdit = () => {
setPreedit(null);
setEditing(false);
if (saveIn > 0) save();
setScrollV(Math.max(0, Math.min(maxScrollV(), scrollV())));
rehover = true;
};

const handleKey = (k: string, shift = false) => {
Expand Down Expand Up @@ -437,14 +431,13 @@ export default function Note(): ReturnType<typeof View> {
}
};

// ---- pointer gestures over the content (svc mouse stream) --------------
// Chrome (toggle, menu) rides the framework's hover-focus + CIRCLE press;
// content needs press/drag/release, which BTN bits can't carry.
// ---- pointer gestures over the content ---------------------------------
// Chrome activation is framework-owned; content consumes the same ordered
// edge batch for caret placement and drag selection.
let press: { x: number; y: number; dragged: boolean; content: boolean } | null = null;
/** Preview selection anchor — persists across clicks so shift-click
* extends from the last plain click. */
let pvAnchor: RowPos | null = null;
let prevDown = false;

const editPosAt = (x: number, y: number): number => {
const line = Math.floor((y - HEADER_H + scrollE() - EDGE_PAD) / BODY_LINE_H);
Expand Down Expand Up @@ -545,19 +538,6 @@ export default function Note(): ReturnType<typeof View> {
case "key":
if (ev.k) handleKey(ev.k, ev.sh ?? false);
break;
case "mouse": {
const p = { x: ev.x ?? -1, y: ev.y ?? -1 };
const down = ev.d ?? false;
setMouse(p);
if (down && !prevDown) pointerDown(p.x, p.y, ev.sh ?? false);
else if (down) pointerMove(p.x, p.y, true);
if (!down && prevDown) pointerUp(p.x, p.y);
prevDown = down;
const n = hitFocusable(p.x, p.y);
if (n && n !== lastHover) focusNode(n);
lastHover = n;
break;
}
case "scroll": {
const dy = ev.dy ?? 0;
if (editing()) setScrollE(Math.max(0, Math.min(maxScrollE(), scrollE() - dy)));
Expand All @@ -570,6 +550,26 @@ export default function Note(): ReturnType<typeof View> {
let lastCaretRect = { x: -1, y: -1, h: -1 };
onFrame(() => {
if (saveIn > 0 && --saveIn === 0) save();
for (const event of pointerEvents()) {
switch (event.type) {
case "down":
if (event.button === 0) pointerDown(event.x, event.y, event.shift);
break;
case "move":
pointerMove(event.x, event.y, true);
break;
case "up":
if (event.button === 0) pointerUp(event.x, event.y);
break;
case "leave":
// A held pointer may re-enter; keep the selection capture but stop
// extending it while no logical position exists.
break;
case "cancel":
press = null;
break;
}
}
if (!svc) return;
for (const ev of svc.poll()) handleEvent(ev);
if (editing()) {
Expand All @@ -583,17 +583,6 @@ export default function Note(): ReturnType<typeof View> {
svc.send({ t: "caret", ...rect });
}
}
if (rehover) {
// The frame after a mode switch: the node under the pointer was
// remounted, so hover-focus it again without waiting for a move.
rehover = false;
const m = mouse();
if (m.x >= 0) {
const n = hitFocusable(m.x, m.y);
if (n) focusNode(n);
lastHover = n;
}
}
});

// Pointerless hosts (PSP, sim): d-pad scrolls the rendered note.
Expand Down
18 changes: 5 additions & 13 deletions apps/note/svc.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// apps/note/svc.ts — the widget host protocol over the spec svc channel
// (ops 30..32, HostOps svcOpen/svcPoll/svcSend).
//
// The desktop widget host is the app's companion process: it forwards the
// real keyboard, mouse and window into the guest as JSON lines, and the
// guest sends intents (save, quit) back. One poll per frame, per the
// The desktop widget host is the app's companion process: it forwards text,
// wheel and window data as JSON lines (real pointer input uses frame input),
// and the guest sends intents (save, quit) back. One poll per frame, per the
// HostOps contract. Hosts without the channel (goldens, hosts/sim, PSP)
// feature-detect to null and the app runs standalone on its sample doc —
// an unmodified-app base case, per docs/RUNTIMES.md rule 5.
Expand All @@ -21,10 +21,6 @@
// {t:"ime", s, c} IME composition: preedit text + caret char
// index within it (null clears); commits
// arrive as plain {t:"ch"} lines
// {t:"mouse", x, y, d, sh} pointer moved / pressed / released — d is
// the primary-button state (a line is sent on
// every press/release even without movement),
// sh the shift modifier (extends selections)
// {t:"scroll", dy} wheel delta in logical px
//
// guest → host lines:
Expand All @@ -39,17 +35,13 @@
import { getOps } from "@pocketjs/framework";

export interface HostEvent {
t: "hello" | "resize" | "load" | "ch" | "key" | "mouse" | "scroll" | "paste" | "ime";
t: "hello" | "resize" | "load" | "ch" | "key" | "scroll" | "paste" | "ime";
w?: number;
h?: number;
text?: string;
s?: string;
k?: string;
x?: number;
y?: number;
/** Primary mouse button held ("mouse" events). */
d?: boolean;
/** Shift held (mouse presses and named keys) — extends selections. */
/** Shift held for named editing keys — extends selections. */
sh?: boolean;
dy?: number;
/** IME preedit caret (char index into s), null when composition ends. */
Expand Down
6 changes: 3 additions & 3 deletions contracts/spec/platforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,9 @@ export const POCKET_CAPABILITIES = defineCapabilityRegistry([
// input.text — a host can have a keyboard without an IME.
"input.ime",
// A REAL absolute pointer (mouse/trackpad): position plus press/drag/
// release edges, hover resolves focus. A different guarantee than
// input.cursor's synthesized nub-pointer, hence a different id (see the
// header rule).
// release/leave/cancel edges in versioned frame argument 5; hover resolves
// focus. A different guarantee than input.cursor's synthesized nub-pointer,
// hence a different id (see the header rule and docs/POINTER.md).
"input.pointer",
// A hardware text stream: layout-applied characters plus named editing
// keys (Backspace/Enter/arrows/Home/End/…), key repeat included. The OSK
Expand Down
14 changes: 8 additions & 6 deletions docs/DEVTOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

PocketJS is a **closed, deterministic world**: the core ticks a fixed 1/60 s
step (`spec.FIXED_DT`), animation clocks count frames (never wall time), the
runtime bans schedulers/RNG/wall-clock, and the *entire* per-frame input is one
PSP button bitmask passed through `globalThis.frame(buttons)`. Frame content is
a pure function of frame index — that is already what byte-exact goldens rely
on.
runtime bans schedulers/RNG/wall-clock, and every per-frame input track passes
through `globalThis.frame`: buttons, analog, touch, and the versioned input
extension used by real pointer edges. Frame content is a pure function of
frame index — that is already what byte-exact goldens rely on.

DevTools turns that property into debugging capabilities that open-world
frameworks (browser, RN, Flutter) structurally cannot offer:
Expand Down Expand Up @@ -69,8 +69,10 @@ poll transport → flush outbox → (paused? maybe step : record + run frame)
```

- **Flight recorder (always on, even with no transport):** every frame's mask
goes into a `Uint16Array` ring (36 000 frames ≈ 10 min ≈ 72 KB). Any crash
or "what just happened?" moment can be exported after the fact.
and analog value go into typed-array rings; touch and versioned frame-input
payloads allocate sparse tracks only when used. Pointer batches retain exact
edge order, including down+up in one tick. Any crash or "what just happened?"
moment can be exported after the fact.
- **Component tree:** serialized from the existing JS mirror tree
(`NodeMirror`), so reads never cross FFI. Semantic names come from
(a) a `debugName` prop on any host component and (b) the `<Named
Expand Down
59 changes: 59 additions & 0 deletions docs/POINTER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Real pointer input

`input.pointer` is the framework-owned path for a mouse or trackpad. It is
separate from `input.cursor`: a real pointer supplies absolute logical
coordinates and ordered host edges; the virtual cursor integrates a nub and
owns a framework-rendered sprite.

## Frame contract

Hosts keep the four legacy tracks and append one versioned extension:

```ts
frame(buttons, analog, touches, touchHits, {
v: 1,
pointer: [
[POINTER_EVENT.MOVE, x, y],
[POINTER_EVENT.DOWN, x, y, 0, modifiers],
[POINTER_EVENT.UP, x, y, 0, modifiers],
],
});
```

The fifth argument is the only extension point for new frame-input families.
Version 1's `pointer` value is an ordered batch. Coordinates are finite JS
numbers in logical viewport pixels; they are not packed, so the
`macos-widget` 4096x4096 viewport remains exact. Button `0` is primary and
modifier bit `1` is Shift.

Events, not sampled levels, are authoritative:

- `MOVE` enters/moves and performs hover-to-focus.
- `DOWN` arms the focusable under the point and applies `active:`.
- `UP` over the armed node fires its bubbling `onPress`; release elsewhere
cancels the click.
- `LEAVE` clears hover and the active look while retaining capture, allowing a
held pointer to re-enter.
- `CANCEL` (focus loss, host-owned window drag, device loss) clears capture
without firing.

`DOWN` followed by `UP` in one batch is a complete fast click and fires once.
The framework reads the host's live `__viewport` for every position event, so
resizes cannot retain stale bounds.

Applications that need lower-level gestures read `pointerEvents()` from
`@pocketjs/framework/input`. Focus, `active:`, and `onPress` remain automatic;
apps do not synthesize button masks or call cursor sprite operations.

## Native hosts and replay

`pocket-mod::Guest::frame_with_input` is the native QuickJS bridge. Its
`FrameInput` and `PointerEvent` types preserve event order and full-resolution
coordinates. `note-widget` is the stock receipt: winit mouse input reaches the
framework through this method, while Note reads the same events for caret and
drag selection.

The flight recorder stores non-empty fifth-argument payloads as the sparse
v3 tape `input` track. Replay owns that track and scrubs live pointer hardware;
v1/v2 tapes replay with no pointer events. Hit results are derived again from
the same committed layout, as touch replay already does for omitted hit facts.
16 changes: 8 additions & 8 deletions docs/WIDGET.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,9 @@ one `render_words_scaled` pass on dirty frames. It exercises everything the
windows keep macOS edge-resize; the shell also tracks an explicit
grip-corner drag (`resize_at`, `WidgetConfig::resizable`/`min_size`).
- **The svc channel is the desktop companion contract.** The spec mailbox
(ops 30..32) needs no new ops for a host that lives in-process: real
keyboard/mouse/wheel/resize go to the guest as JSON lines
(`{t:"ch"|"key"|"mouse"|"scroll"|"resize"|"load"}`), save/quit intents
(ops 30..32) carries text/wheel/resize data as JSON lines
(`{t:"ch"|"key"|"scroll"|"resize"|"load"}`), while the real mouse uses
the versioned frame-input contract ([POINTER.md](./POINTER.md)). Save/quit intents
come back (`{t:"save"|"quit"}`). The app source retains a svc-less
read-only fallback, but the current Note manifest is dynamic-only; it must
add a fixed viewport variant before a PSP or embedded host can admit that
Expand All @@ -145,11 +145,11 @@ one `render_words_scaled` pass on dirty frames. It exercises everything the
assert identity (`__host`/`__hostAbi` vs the plan's target), and
`bun run note` builds through the manifest — density and features come from
the profile, not flags.
- **Clicks are CIRCLE.** The host synthesizes the spec press button while
the mouse is down; the app resolves hover → focus (`hitFocusable` +
`focusNode`) from svc mouse moves, and the framework's stock onPress
pipeline dispatches — including into Portal overlays (the hit-test root
now spans the overlay layer, fixing menus for every cursor-mode app).
- **Clicks are pointer edges.** The host delivers full-resolution ordered
move/down/up/leave/cancel events through `Guest::frame_with_input`.
Framework hover-focus, `active:`, cancellation and onPress dispatch need no
synthesized CIRCLE or private mouse messages; Note reads `pointerEvents()`
only for its content caret/drag selection.
- **Text editing without an OSK.** The `pocket3d` `Input` grew a per-frame
edit-keystroke stream (chars with layout applied, named keys, repeats)
and a wheel accumulator; the guest's editor (measured soft wrap, caret
Expand Down
1 change: 1 addition & 0 deletions engine/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading