diff --git a/README.md b/README.md index c2b70e5b..b8b8b8e8 100644 --- a/README.md +++ b/README.md @@ -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/) | diff --git a/apps/note/app.tsx b/apps/note/app.tsx index 5347dac9..56dd217f 100644 --- a/apps/note/app.tsx +++ b/apps/note/app.tsx @@ -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 { @@ -157,7 +157,6 @@ export default function Note(): ReturnType { 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); @@ -232,9 +231,6 @@ export default function Note(): ReturnType { 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; @@ -302,14 +298,12 @@ export default function Note(): ReturnType { 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) => { @@ -437,14 +431,13 @@ export default function Note(): ReturnType { } }; - // ---- 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); @@ -545,19 +538,6 @@ export default function Note(): ReturnType { 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))); @@ -570,6 +550,26 @@ export default function Note(): ReturnType { 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()) { @@ -583,17 +583,6 @@ export default function Note(): ReturnType { 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. diff --git a/apps/note/svc.ts b/apps/note/svc.ts index 3ae33485..7db47ad2 100644 --- a/apps/note/svc.ts +++ b/apps/note/svc.ts @@ -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. @@ -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: @@ -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. */ diff --git a/contracts/spec/platforms.ts b/contracts/spec/platforms.ts index 88ece438..129079a0 100644 --- a/contracts/spec/platforms.ts +++ b/contracts/spec/platforms.ts @@ -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 diff --git a/docs/DEVTOOLS.md b/docs/DEVTOOLS.md index 3d5fc50f..09fb76d4 100644 --- a/docs/DEVTOOLS.md +++ b/docs/DEVTOOLS.md @@ -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: @@ -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 ` Self { + Self { + kind, + x, + y, + button: 0, + modifiers: 0, + } + } + + pub const fn boundary(kind: PointerEventKind) -> Self { + Self::at(kind, 0.0, 0.0) + } +} + +/// Version 1 host input appended as frame() argument 5. The first four +/// positional tracks remain buttons, analog, touches, and touch hit facts. +pub struct FrameInput<'a> { + pub pointer: &'a [PointerEvent], +} + /// One QuickJS realm hosting one guest program. pub struct Guest { rt: Runtime, @@ -136,6 +183,79 @@ impl Guest { Ok(()) } + /// One guest turn with the versioned frame-input extension. Pointer + /// events are ordered edges, so `[Down, Up]` in one slice preserves a + /// complete fast click. Leave and Cancel are explicit and never inferred + /// from a missing sampled level. + pub fn frame_with_input( + &self, + buttons: u32, + analog: u32, + touches: &[u32], + touch_hits: &[u32], + input: &FrameInput<'_>, + ) -> Result<()> { + self.ctx.with(|ctx| -> Result<()> { + let frame: Option = ctx.globals().get("frame").ok(); + if let Some(frame) = frame { + let touch_arr = rquickjs::Array::new(ctx.clone()) + .map_err(|e| anyhow!("pocket-mod: allocating touch array: {e}"))?; + for (i, value) in touches.iter().enumerate() { + touch_arr + .set(i, *value) + .map_err(|e| anyhow!("pocket-mod: setting touch {i}: {e}"))?; + } + let hit_arr = rquickjs::Array::new(ctx.clone()) + .map_err(|e| anyhow!("pocket-mod: allocating touch-hit array: {e}"))?; + for (i, value) in touch_hits.iter().enumerate() { + hit_arr + .set(i, *value) + .map_err(|e| anyhow!("pocket-mod: setting touch hit {i}: {e}"))?; + } + let payload = Object::new(ctx.clone()) + .map_err(|e| anyhow!("pocket-mod: allocating frame input: {e}"))?; + payload + .set("v", 1u8) + .map_err(|e| anyhow!("pocket-mod: setting frame input version: {e}"))?; + let pointer = rquickjs::Array::new(ctx.clone()) + .map_err(|e| anyhow!("pocket-mod: allocating pointer batch: {e}"))?; + for (i, event) in input.pointer.iter().enumerate() { + let raw = rquickjs::Array::new(ctx.clone()) + .map_err(|e| anyhow!("pocket-mod: allocating pointer event {i}: {e}"))?; + raw.set(0, event.kind as u8) + .map_err(|e| anyhow!("pocket-mod: setting pointer kind {i}: {e}"))?; + if !matches!( + event.kind, + PointerEventKind::Leave | PointerEventKind::Cancel + ) { + raw.set(1, event.x) + .map_err(|e| anyhow!("pocket-mod: setting pointer x {i}: {e}"))?; + raw.set(2, event.y) + .map_err(|e| anyhow!("pocket-mod: setting pointer y {i}: {e}"))?; + raw.set(3, event.button) + .map_err(|e| anyhow!("pocket-mod: setting pointer button {i}: {e}"))?; + raw.set(4, event.modifiers).map_err(|e| { + anyhow!("pocket-mod: setting pointer modifiers {i}: {e}") + })?; + } + pointer + .set(i, raw) + .map_err(|e| anyhow!("pocket-mod: setting pointer event {i}: {e}"))?; + } + payload + .set("pointer", pointer) + .map_err(|e| anyhow!("pocket-mod: setting pointer batch: {e}"))?; + frame + .call::<_, ()>((buttons, analog, touch_arr, hit_arr, payload)) + .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 +441,40 @@ mod tests { assert_eq!(res, "0:0:-1"); } + #[test] + fn frame_carries_ordered_full_resolution_pointer_edges() { + let g = Guest::new().unwrap(); + g.eval( + "boot", + "globalThis.res = ''; \ + globalThis.frame = (_b, _a, _t, _h, input) => { \ + globalThis.res = input.v + ':' + input.pointer.map(e => e.join(',')).join('|'); \ + };", + ) + .unwrap(); + let events = [ + PointerEvent { + kind: PointerEventKind::Down, + x: 4095.5, + y: 3071.25, + button: 0, + modifiers: 1, + }, + PointerEvent::at(PointerEventKind::Up, 4095.5, 3071.25), + PointerEvent::boundary(PointerEventKind::Cancel), + ]; + g.frame_with_input( + 0, + pocketjs_core::spec::ANALOG_CENTER, + &[], + &[], + &FrameInput { pointer: &events }, + ) + .unwrap(); + let res: String = g.with(|ctx| ctx.globals().get("res").unwrap()); + assert_eq!(res, "1:1,4095.5,3071.25,0,1|2,4095.5,3071.25,0,0|4"); + } + #[test] fn exceptions_carry_js_stack() { let g = Guest::new().unwrap(); diff --git a/engine/crates/pocket-widget/src/shell.rs b/engine/crates/pocket-widget/src/shell.rs index c227b189..503e31f4 100644 --- a/engine/crates/pocket-widget/src/shell.rs +++ b/engine/crates/pocket-widget/src/shell.rs @@ -586,13 +586,13 @@ impl ApplicationHandler for WidgetApp { state.resizing = Some((cursor, size)); // The grip press is a window gesture, not app // input — take the button back. - state.input.inject_mouse_button(MouseButton::Left, false); + state.input.cancel_mouse_button(MouseButton::Left); } else if self.driver.drag_at(cursor) { let _ = state.window.drag_window(); // macOS swallows the release once the OS drag // session starts; clear the button so the next // press edges. - state.input.inject_mouse_button(MouseButton::Left, false); + state.input.cancel_mouse_button(MouseButton::Left); } } } diff --git a/engine/pocket3d/README.md b/engine/pocket3d/README.md index 0afe3c4e..2ba788ab 100644 --- a/engine/pocket3d/README.md +++ b/engine/pocket3d/README.md @@ -135,9 +135,9 @@ The first *flat* pocket-widget runtime: no scene at all — the borderless, resizable, always-on-top window IS a live `ui` surface, rendered at Retina density (density-2 pak + `render_words_scaled`) and demand-driven like every widget. The guest is `apps/note` (markdown view/edit, popup menu); the host -forwards the real keyboard/mouse/wheel/resize over the spec svc channel and -synthesizes CIRCLE for clicks, so the framework's hover-focus + onPress -pipeline does all dispatch. +forwards text/wheel/resize over svc and sends its mouse through the versioned +frame-input pointer batch. The framework owns hover-focus, cancellation and +onPress; the app reads the same edges for caret/drag selection. ```sh bun tools/build.ts note-main --density=2 # from the repo root diff --git a/engine/pocket3d/crates/pocket3d/src/input.rs b/engine/pocket3d/crates/pocket3d/src/input.rs index d61ee301..14cbe328 100644 --- a/engine/pocket3d/crates/pocket3d/src/input.rs +++ b/engine/pocket3d/crates/pocket3d/src/input.rs @@ -191,6 +191,7 @@ impl Input { self.mouse_down.clear(); self.mouse_pressed.clear(); self.mouse_delta = Vec2::ZERO; + self.cursor = None; self.edits.clear(); self.ime.clear(); self.scroll = Vec2::ZERO; @@ -293,6 +294,16 @@ impl Input { } } + /// Host policy consumed this press (window move/resize, mode switch): + /// remove both level and pending edge, and publish a cancellation edge to + /// pointer consumers. This is deliberately distinct from an injected UP. + pub fn cancel_mouse_button(&mut self, button: MouseButton) { + let id = button_id(button); + self.mouse_down.remove(&id); + self.mouse_pressed.remove(&id); + self.interaction_cancelled = true; + } + pub fn inject_mouse_delta(&mut self, dx: f32, dy: f32) { self.mouse_delta += Vec2::new(dx, dy); } @@ -396,8 +407,10 @@ mod tests { assert!(!input.scroll_gesture_ended()); input.inject_mouse_button(MouseButton::Left, true); + input.inject_cursor(12.0, 34.0); input.on_window_event(&WindowEvent::Focused(false)); assert!(!input.mouse_button_down(MouseButton::Left)); + assert_eq!(input.cursor(), None); assert!(input.scroll_gesture_ended()); assert!(input.interaction_cancelled()); @@ -405,4 +418,14 @@ mod tests { assert!(!input.scroll_gesture_ended()); assert!(!input.interaction_cancelled()); } + + #[test] + fn host_consumed_mouse_press_becomes_cancel_not_fast_click() { + let mut input = Input::default(); + input.inject_mouse_button(MouseButton::Left, true); + input.cancel_mouse_button(MouseButton::Left); + assert!(!input.mouse_button_pressed(MouseButton::Left)); + assert!(!input.mouse_button_down(MouseButton::Left)); + assert!(input.interaction_cancelled()); + } } diff --git a/engine/pocket3d/examples/note-widget/Cargo.toml b/engine/pocket3d/examples/note-widget/Cargo.toml index 66d956dc..f020d49c 100644 --- a/engine/pocket3d/examples/note-widget/Cargo.toml +++ b/engine/pocket3d/examples/note-widget/Cargo.toml @@ -9,6 +9,7 @@ description = "A markdown sticky note on the desktop: the first flat (2D) pocket [dependencies] pocket3d = { workspace = true } pocket-mod = { workspace = true } +pocketjs-core = { workspace = true } pocket-ui-wgpu = { workspace = true } pocket-widget = { workspace = true } wgpu = { workspace = true } diff --git a/engine/pocket3d/examples/note-widget/src/main.rs b/engine/pocket3d/examples/note-widget/src/main.rs index dd218f36..3d93367c 100644 --- a/engine/pocket3d/examples/note-widget/src/main.rs +++ b/engine/pocket3d/examples/note-widget/src/main.rs @@ -12,10 +12,11 @@ //! cargo run -p note-widget -- --file ~/notes/todo.md --width 380 --height 520 //! //! The host is the guest's companion process over the spec svc channel -//! (ops 30..32): real keyboard/mouse/wheel/resize go in as JSON lines, -//! save/quit intents come back. Clicks synthesize BTN_CIRCLE, so the -//! framework's hover-focus + onPress pipeline dispatches them — the app -//! never sees a platform event, only spec inputs. Drag the header to move, +//! (ops 30..32): keyboard/wheel/resize go in as JSON lines and save/quit +//! intents come back. Mouse input uses pocket-mod's versioned frame-input +//! pointer batch, so hover, fast clicks, drag, leave and cancellation all +//! reach the framework without private svc messages or synthesized buttons. +//! Drag the header to move, //! drag the dotted corner (or any edge, macOS) to resize, ⌘Q/⌘W quits. mod cjk; @@ -24,11 +25,11 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use glam::Vec2; -use pocket3d::gpu::{Gpu, OFFSCREEN_FORMAT, OffscreenTarget}; -use pocket3d::input::{EditKey, ImeInput, Input}; -use pocket_mod::Guest; +use pocket_mod::{FrameInput, Guest, PointerEvent, PointerEventKind}; use pocket_ui_wgpu::{UiRenderer, UiSurface}; use pocket_widget::shell::{FlatWidget, WidgetConfig}; +use pocket3d::gpu::{Gpu, OFFSCREEN_FORMAT, OffscreenTarget}; +use pocket3d::input::{EditKey, ImeInput, Input}; use winit::keyboard::KeyCode; /// Header strip height in logical px — mirrors HEADER_H in apps/note/app.tsx. @@ -37,8 +38,6 @@ const HEADER_H: f32 = 30.0; const HEADER_BUTTONS_W: f32 = 112.0; /// Resize grip square in the bottom-right corner, logical px. const GRIP: f32 = 18.0; -/// The spec CIRCLE bit — the framework's onPress button. -const BTN_CIRCLE: u32 = 0x2000; /// Ticks a scripted drag takes from press to its final position. const DRAG_TICKS: u64 = 8; @@ -60,9 +59,10 @@ struct NoteGame { dirty: bool, exit: bool, booted: bool, - /// Last (x, y, primary-down) sent over svc — mouse lines go out on any - /// change, including press/release without movement. + /// Last logical position + primary level used to derive ordered pointer + /// edges. The level is host state only; the guest receives explicit edges. last_mouse: Option<(f32, f32, bool)>, + pointer_inside: bool, /// The guest's ••• menu is up: stop claiming header drags/resizes so /// clicks anywhere reach the backdrop and close it. guest_menu_open: bool, @@ -115,6 +115,7 @@ impl NoteGame { exit: false, booted: false, last_mouse: None, + pointer_inside: false, guest_menu_open: false, scale: 1.0, ticks: 0, @@ -246,23 +247,16 @@ impl NoteGame { let (_, ev) = self.script.remove(i); match ev { ScriptEvent::Click(x, y) => { - // Hover first (focuses the target), then hold CIRCLE for - // a few ticks — the same order a real pointer produces. - self.svc(serde_json::json!({"t": "mouse", "x": x, "y": y, "d": false})); self.script_click_until = self.ticks + 4; self.script_shift = false; self.script_drag = Some((x, y, x, y, self.ticks)); } ScriptEvent::ShiftClick(x, y) => { - self.svc( - serde_json::json!({"t": "mouse", "x": x, "y": y, "d": false, "sh": true}), - ); self.script_click_until = self.ticks + 4; self.script_shift = true; self.script_drag = Some((x, y, x, y, self.ticks)); } ScriptEvent::Drag(x0, y0, x1, y1) => { - self.svc(serde_json::json!({"t": "mouse", "x": x0, "y": y0, "d": false})); self.script_click_until = self.ticks + DRAG_TICKS + 2; self.script_drag = Some((x0, y0, x1, y1, self.ticks)); } @@ -344,7 +338,7 @@ impl FlatWidget for NoteGame { self.svc(serde_json::json!({"t": "resize", "w": logical.0, "h": logical.1})); } - // Keyboard / wheel / pointer → svc lines (logical px). + // Keyboard / wheel → svc lines (the pointer uses frame input below). self.forward_edits(input); self.forward_ime(input); let scroll = input.scroll(); @@ -357,16 +351,12 @@ impl FlatWidget for NoteGame { } let script_down = self.ticks < self.script_click_until; // Down-edge OR level: a fast click can press AND release inside one - // 60 Hz tick — level sampling alone would drop it entirely (the - // guest would see no press, no CIRCLE, and a stale selection). + // 60 Hz tick. The frame input below preserves both ordered edges. let pressed_edge = input.mouse_button_pressed(winit::event::MouseButton::Left); - let level_down = - input.mouse_button_down(winit::event::MouseButton::Left) || script_down; - let mouse_down = level_down || pressed_edge; + let level_down = input.mouse_button_down(winit::event::MouseButton::Left) || script_down; - // Pointer → svc: one line per (position, button) change, so the - // guest sees press and release edges even without movement. A - // release with the cursor gone reuses the last known position. + // Pointer coordinates are logical numbers, never packed — the stock + // macos-widget contract permits a 4096x4096 live viewport. let pos = if let Some((x0, y0, x1, y1, start)) = self.script_drag { let t = ((self.ticks.saturating_sub(start)) as f32 / DRAG_TICKS as f32).min(1.0); if t >= 1.0 && !script_down { @@ -378,33 +368,70 @@ impl FlatWidget for NoteGame { input .cursor() .map(|c| (c.x / scale as f32, c.y / scale as f32)) - .or(self.last_mouse.map(|(x, y, _)| (x, y))) }; let shift = input.key_down(KeyCode::ShiftLeft) || input.key_down(KeyCode::ShiftRight) || self.script_shift; - if let Some((x, y)) = pos { + let modifiers = u8::from(shift); + let mut pointer = Vec::with_capacity(3); + let event = |kind, x: f32, y: f32| PointerEvent { + kind, + x: x as f64, + y: y as f64, + button: 0, + modifiers, + }; + + if input.interaction_cancelled() { + pointer.push(PointerEvent::boundary(PointerEventKind::Cancel)); + self.last_mouse = None; + self.pointer_inside = false; + } else if let Some((x, y)) = pos { + let previous_down = self.last_mouse.is_some_and(|(_, _, down)| down); + let moved = !self.pointer_inside + || self + .last_mouse + .is_none_or(|(last_x, last_y, _)| last_x != x || last_y != y); + if moved { + pointer.push(event(PointerEventKind::Move, x, y)); + } if pressed_edge && !level_down { - // The whole click fit inside this tick: deliver both edges - // in order so the guest still runs press → release. - self.svc(serde_json::json!({"t": "mouse", "x": x, "y": y, "d": true, "sh": shift})); - self.svc(serde_json::json!({"t": "mouse", "x": x, "y": y, "d": false, "sh": shift})); - self.last_mouse = Some((x, y, false)); - } else { - let m = (x, y, mouse_down); - if self.last_mouse != Some(m) { - self.last_mouse = Some(m); - self.svc( - serde_json::json!({"t": "mouse", "x": x, "y": y, "d": mouse_down, "sh": shift}), - ); + // The complete click fit before this tick. Never collapse it + // back into an up level: DOWN then UP must reach the guest. + pointer.push(event(PointerEventKind::Down, x, y)); + pointer.push(event(PointerEventKind::Up, x, y)); + } else if !previous_down && level_down { + pointer.push(event(PointerEventKind::Down, x, y)); + } else if previous_down && !level_down { + pointer.push(event(PointerEventKind::Up, x, y)); + } + self.last_mouse = Some((x, y, level_down)); + self.pointer_inside = true; + } else { + let previous_down = self.last_mouse.is_some_and(|(_, _, down)| down); + if self.pointer_inside { + pointer.push(PointerEvent::boundary(PointerEventKind::Leave)); + } + if previous_down && !level_down { + // A release outside cannot be a successful click at the last + // in-window coordinate. Cancel the capture explicitly. + pointer.push(PointerEvent::boundary(PointerEventKind::Cancel)); + if let Some((x, y, _)) = self.last_mouse { + self.last_mouse = Some((x, y, false)); } } + self.pointer_inside = false; } - // The guest turn (Law 3: exactly one per tick). Clicks are CIRCLE — - // hover already focused what's under the pointer. - let buttons = if mouse_down { BTN_CIRCLE } else { 0 }; - self.guest.frame(buttons)?; + // The guest turn (Law 3: exactly one per tick). Real pointer input is + // frame argument 5; the button mask stays independent and empty. + self.guest.frame_with_input( + 0, + pocketjs_core::spec::ANALOG_CENTER, + &[], + &[], + &FrameInput { pointer: &pointer }, + )?; self.surface.tick(); // Guest → host intents. diff --git a/framework/src/devtools.ts b/framework/src/devtools.ts index f7b3b4ee..3c0745d7 100644 --- a/framework/src/devtools.ts +++ b/framework/src/devtools.ts @@ -11,6 +11,7 @@ // freeze is ui.debugPause (spec op 21). import { ANALOG_CENTER } from "../../contracts/spec/spec.ts"; +import { cloneFrameInput, type FrameInput } from "./frame-input.ts"; import type { HostOps } from "./host.ts"; import { rootMirror, setTreeMutationHook, type NodeMirror } from "./native-tree.ts"; @@ -25,7 +26,7 @@ export interface DevtoolsTransport { /** Input tape: the complete session input, RLE-encoded (docs/DEVTOOLS.md §4). */ export interface Tape { - v: 1 | 2; + v: 1 | 2 | 3; app?: string; /** Total frames represented by `masks`. */ frames: number; @@ -42,6 +43,10 @@ export interface Tape { * touch-free session exports v:1 with no track — byte-identical to * pre-touch tapes — and replays every frame as no-contacts. */ touch?: [number, number[]][]; + /** v3: sparse versioned frame-input payloads. Pointer edge batches live + * here, including multiple edges in one tick; replay restores the exact + * ordered payload and never samples a live pointer level. */ + input?: [number, FrameInput][]; /** Absolute frame index of masks[0] (0 unless the ring wrapped). */ startFrame?: number; } @@ -62,6 +67,8 @@ interface DevtoolsState { /** Touch ring — allocated lazily on the first frame that HAS contacts, so * touch-free sessions (every PSP session) never pay for it. */ tapeTouch: (number[] | null)[] | null; + /** Versioned frame-input ring, also lazy for pointer-free devices. */ + tapeInput: (FrameInput | null)[] | null; tapeStart: number; // ring index of the oldest frame tapeLen: number; tapeFirstFrame: number; // absolute frame index of the oldest entry @@ -69,6 +76,7 @@ interface DevtoolsState { replayMasks: Uint16Array | null; replayAnalog: Uint16Array | null; replayTouch: (number[] | undefined)[] | null; + replayInput: (FrameInput | undefined)[] | null; replayAt: number; // pause paused: boolean; @@ -93,12 +101,14 @@ const state: DevtoolsState = { tape: new Uint16Array(TAPE_CAP), tapeAnalog: new Uint16Array(TAPE_CAP), tapeTouch: null, + tapeInput: null, tapeStart: 0, tapeLen: 0, tapeFirstFrame: 0, replayMasks: null, replayAnalog: null, replayTouch: null, + replayInput: null, replayAt: 0, paused: false, stepQueued: 0, @@ -132,9 +142,11 @@ export function initDevtools(ops: HostOps): void { state.tapeLen = 0; state.tapeFirstFrame = 0; state.tapeTouch = null; + state.tapeInput = null; state.replayMasks = null; state.replayAnalog = null; state.replayTouch = null; + state.replayInput = null; state.paused = false; state.stepQueued = 0; state.inspectReportId = null; @@ -174,13 +186,26 @@ 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[], hits?: readonly number[]) => void, -): (buttons: number, analog?: number, touches?: readonly number[], hits?: readonly number[]) => void { + h: ( + buttons: number, + analog: number, + touches?: readonly number[], + hits?: readonly number[], + input?: FrameInput, + ) => void, +): ( + buttons: number, + analog?: number, + touches?: readonly number[], + hits?: readonly number[], + input?: FrameInput, +) => void { return ( buttons: number, analogArg?: number, touchArg?: readonly number[], hitsArg?: readonly number[], + inputArg?: FrameInput, ) => { state.hostCalls++; if (state.transport) { @@ -191,6 +216,7 @@ export function wrapFrameHandler( let analog = analogArg === undefined ? ANALOG_CENTER : analogArg & 0xffff; let touch = touchArg; let hits = hitsArg; + let input = inputArg; if (state.replayMasks) { if (state.replayAt < state.replayMasks.length) { mask = state.replayMasks[state.replayAt]; @@ -205,11 +231,15 @@ export function wrapFrameHandler( // (op 42/27 against the same committed layout — the same answer the // recording host computed). hits = undefined; + // The versioned extension is an owned tape track too. Old tapes have + // no payload and therefore scrub all live pointer events. + input = state.replayInput ? state.replayInput[state.replayAt] : undefined; state.replayAt++; } else { state.replayMasks = null; // tape exhausted: back to live input state.replayAnalog = null; state.replayTouch = null; + state.replayInput = null; send({ t: "replayDone", frame: state.frame }); } } @@ -218,10 +248,10 @@ export function wrapFrameHandler( state.stepQueued--; state.ops?.debugStep?.(); // arm exactly one core tick } - recordMask(mask, analog, touch); + recordMask(mask, analog, touch, input); state.frame++; try { - h(mask, analog, touch, hits); + h(mask, analog, touch, hits, input); } catch (e) { send({ t: "error", @@ -239,7 +269,12 @@ export function wrapFrameHandler( // tape // --------------------------------------------------------------------------- -function recordMask(mask: number, analog: number, touch?: readonly number[]): void { +function recordMask( + mask: number, + analog: number, + touch?: readonly number[], + input?: FrameInput, +): void { // Defensive copy: hosts may reuse the packed-contact buffer across frames. const contacts = touch && touch.length > 0 ? touch.slice(0, 8) : null; if (contacts && !state.tapeTouch) { @@ -247,16 +282,22 @@ function recordMask(mask: number, analog: number, touch?: readonly number[]): vo // never reach here). Frames recorded before this point had no contacts. state.tapeTouch = new Array(TAPE_CAP).fill(null); } + const frameInput = cloneFrameInput(input) ?? null; + if (frameInput && !state.tapeInput) { + state.tapeInput = new Array(TAPE_CAP).fill(null); + } if (state.tapeLen < TAPE_CAP) { const at = (state.tapeStart + state.tapeLen) % TAPE_CAP; state.tape[at] = mask; state.tapeAnalog[at] = analog; if (state.tapeTouch) state.tapeTouch[at] = contacts; + if (state.tapeInput) state.tapeInput[at] = frameInput; state.tapeLen++; } else { state.tape[state.tapeStart] = mask; state.tapeAnalog[state.tapeStart] = analog; if (state.tapeTouch) state.tapeTouch[state.tapeStart] = contacts; + if (state.tapeInput) state.tapeInput[state.tapeStart] = frameInput; state.tapeStart = (state.tapeStart + 1) % TAPE_CAP; state.tapeFirstFrame++; } @@ -300,6 +341,18 @@ function exportTape(): Tape { tape.touch = touch; } } + if (state.tapeInput) { + const input: [number, FrameInput][] = []; + for (let i = 0; i < state.tapeLen; i++) { + const value = state.tapeInput[(state.tapeStart + i) % TAPE_CAP]; + const copy = cloneFrameInput(value ?? undefined); + if (copy) input.push([i, copy]); + } + if (input.length > 0) { + tape.v = 3; + tape.input = input; + } + } return tape; } @@ -340,6 +393,18 @@ export function expandTapeTouch(tape: Tape): (number[] | undefined)[] { return out; } +/** Expand a tape's sparse versioned frame-input track. Pre-v3 tapes yield + * all undefined so replay scrubs live pointer hardware deterministically. */ +export function expandTapeInput(tape: Tape): (FrameInput | undefined)[] { + let total = 0; + for (const [, n] of tape.masks) total += n; + const out = new Array(total).fill(undefined); + for (const [frame, input] of tape.input ?? []) { + if (frame >= 0 && frame < total) out[frame] = cloneFrameInput(input); + } + return out; +} + // --------------------------------------------------------------------------- // protocol // --------------------------------------------------------------------------- @@ -461,6 +526,7 @@ function handleMessage(line: string): void { state.replayMasks = expandTape(tape); state.replayAnalog = tape.analog ? expandTapeAnalog(tape) : null; state.replayTouch = tape.touch ? expandTapeTouch(tape) : null; + state.replayInput = tape.input ? expandTapeInput(tape) : null; state.replayAt = 0; } break; @@ -669,6 +735,7 @@ const api = { state.replayMasks = expandTape(tape); state.replayAnalog = tape.analog ? expandTapeAnalog(tape) : null; state.replayTouch = tape.touch ? expandTapeTouch(tape) : null; + state.replayInput = tape.input ? expandTapeInput(tape) : null; state.replayAt = 0; }, }; diff --git a/framework/src/frame-input.ts b/framework/src/frame-input.ts new file mode 100644 index 00000000..d368ecbc --- /dev/null +++ b/framework/src/frame-input.ts @@ -0,0 +1,137 @@ +// Versioned per-frame input extensions. +// +// The first four frame() arguments are the stable legacy tracks (buttons, +// analog, touches, touch hit facts). Everything added after those tracks +// travels in this single append-only payload as frame() argument 5. Keeping +// the payload versioned prevents a new input family from silently claiming an +// old positional argument, and lets DevTools record/replay it as one unit. + +export const FRAME_INPUT_VERSION = 1 as const; + +export const POINTER_EVENT = { + MOVE: 0, + DOWN: 1, + UP: 2, + LEAVE: 3, + CANCEL: 4, +} as const; + +export const POINTER_MODIFIER = { + SHIFT: 1, +} as const; + +export type PointerEventCode = (typeof POINTER_EVENT)[keyof typeof POINTER_EVENT]; + +/** + * Compact native wire event. + * + * Position events are `[kind, x, y, button?, modifiers?]`; boundary events + * are `[LEAVE]` or `[CANCEL]`. Coordinates are ordinary finite JS numbers, + * deliberately not bit-packed, so a 4096x4096 logical viewport is exact. + * Button 0 is the primary button. DOWN/UP are edges, not sampled levels, so + * both may occur in one host tick without losing a fast click. + */ +export type PointerWireEvent = readonly [ + kind: PointerEventCode, + x?: number, + y?: number, + button?: number, + modifiers?: number, +]; + +export interface FrameInputV1 { + readonly v: typeof FRAME_INPUT_VERSION; + readonly pointer?: readonly PointerWireEvent[]; +} + +export type FrameInput = FrameInputV1; + +type PointerPositionEvent = Readonly<{ + type: T; + x: number; + y: number; + button: number; + shift: boolean; +}>; + +export type PointerEvent = + | PointerPositionEvent<"move"> + | PointerPositionEvent<"down"> + | PointerPositionEvent<"up"> + | Readonly<{ type: "leave" }> + | Readonly<{ type: "cancel" }>; + +const EMPTY: readonly PointerEvent[] = Object.freeze([]); +let pointerSnapshot: readonly PointerEvent[] = EMPTY; + +function decodePointer(events: readonly PointerWireEvent[] | undefined): readonly PointerEvent[] { + if (!events || events.length === 0) return EMPTY; + const out: PointerEvent[] = []; + for (const raw of events.slice(0, 32)) { + if (!Array.isArray(raw)) continue; + const kind = raw[0]; + if (kind === POINTER_EVENT.LEAVE || kind === POINTER_EVENT.CANCEL) { + out.push(Object.freeze({ type: kind === POINTER_EVENT.LEAVE ? "leave" : "cancel" })); + continue; + } + if (kind !== POINTER_EVENT.MOVE && kind !== POINTER_EVENT.DOWN && kind !== POINTER_EVENT.UP) { + continue; + } + const x = raw[1]; + const y = raw[2]; + if (typeof x !== "number" || !Number.isFinite(x) || typeof y !== "number" || !Number.isFinite(y)) { + continue; + } + out.push( + Object.freeze({ + type: kind === POINTER_EVENT.MOVE ? "move" : kind === POINTER_EVENT.DOWN ? "down" : "up", + x, + y, + button: Number.isInteger(raw[3]) ? Math.max(0, raw[3]!) : 0, + shift: ((raw[4] ?? 0) & POINTER_MODIFIER.SHIFT) !== 0, + }), + ); + } + return out.length === 0 ? EMPTY : Object.freeze(out); +} + +/** Latch frame() argument 5 before lifecycle callbacks run. */ +export function __setFrameInput(input: FrameInput | undefined): void { + pointerSnapshot = input?.v === FRAME_INPUT_VERSION ? decodePointer(input.pointer) : EMPTY; +} + +export function __resetFrameInput(): void { + pointerSnapshot = EMPTY; +} + +/** Ordered real-pointer events delivered during the current host tick. */ +export function pointerEvents(): readonly PointerEvent[] { + return pointerSnapshot; +} + +/** + * Defensive, bounded copy for the flight recorder. Unknown versions are not + * guessed: a newer host must first teach this runtime how to preserve them. + */ +export function cloneFrameInput(input: FrameInput | undefined): FrameInput | undefined { + if (!input || input.v !== FRAME_INPUT_VERSION || !input.pointer?.length) return undefined; + const pointer: PointerWireEvent[] = []; + for (const raw of input.pointer.slice(0, 32)) { + if (!Array.isArray(raw)) continue; + const kind = raw[0]; + if (kind === POINTER_EVENT.LEAVE || kind === POINTER_EVENT.CANCEL) { + pointer.push([kind]); + continue; + } + if (kind !== POINTER_EVENT.MOVE && kind !== POINTER_EVENT.DOWN && kind !== POINTER_EVENT.UP) { + continue; + } + const x = raw[1]; + const y = raw[2]; + if (typeof x !== "number" || !Number.isFinite(x) || typeof y !== "number" || !Number.isFinite(y)) { + continue; + } + pointer.push([kind, x, y, Number.isInteger(raw[3]) ? Math.max(0, raw[3]!) : 0, raw[4] ?? 0]); + } + return pointer.length > 0 ? { v: FRAME_INPUT_VERSION, pointer } : undefined; +} diff --git a/framework/src/host.ts b/framework/src/host.ts index 266d9c94..b3a8823e 100644 --- a/framework/src/host.ts +++ b/framework/src/host.ts @@ -17,6 +17,7 @@ import { VALUE_KIND, type PropName, } from "../../contracts/spec/spec.ts"; +import type { FrameInput } from "./frame-input.ts"; // Replaced by tools/build.ts for manifest-driven builds. `typeof` keeps // legacy/test bundles valid until they opt into a ResolvedBuildPlan. @@ -357,7 +358,8 @@ export function reportAppAction(name: string, value: number): void { // Frame hookup // --------------------------------------------------------------------------- // Every host drives frames the same way: once per vblank/rAF tick it calls -// `globalThis.frame(buttons, analog?, touches?)` with the PSP button bitmask (spec BTN) +// `globalThis.frame(buttons, analog?, touches?, hits?, input?)` with the PSP +// button bitmask (spec BTN) // and, when the host has an analog stick, the packed nub value // (x << 8 | y, each axis 0..255, 128 = center — spec ANALOG_CENTER). Hosts // without a stick pass one argument; the runtime defaults to center, so every @@ -366,7 +368,13 @@ export function reportAppAction(name: string, value: number): void { // via installFrameHandler. export function installFrameHandler( - fn: (buttons: number, analog?: number, touches?: readonly number[]) => void, + fn: ( + buttons: number, + analog?: number, + touches?: readonly number[], + hits?: readonly number[], + input?: FrameInput, + ) => void, ): void { ( globalThis as { @@ -374,6 +382,8 @@ export function installFrameHandler( buttons: number, analog?: number, touches?: readonly number[], + hits?: readonly number[], + input?: FrameInput, ) => void; } ).frame = fn; diff --git a/framework/src/index-octane.ts b/framework/src/index-octane.ts index d534dd0f..2486638c 100644 --- a/framework/src/index-octane.ts +++ b/framework/src/index-octane.ts @@ -28,9 +28,10 @@ import { } from "./renderer-octane.ts"; import { setOverlayRoot } from "./overlay.ts"; import { registerStyles, resolveStyle } from "./styles.ts"; -import { handleFrame, setInputRoot } from "./input.ts"; +import { handleFrame, handlePointerInput, setHitRoot, setInputRoot } from "./input.ts"; import { __setAnalog, resetFrameHooks, runFrameHooks } from "./frame-octane.tsx"; import { __resetTouches, __setTouches } from "./touch.ts"; +import { __resetFrameInput, __setFrameInput, type FrameInput } from "./frame-input.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"; @@ -197,16 +198,25 @@ export function render(code: OctaneRenderRoot, opts: RenderOptions = {}): () => overlayLayer = overlayRoot; setInputRoot(appRoot); + setHitRoot(rootMirror); 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[], + hits?: readonly number[], + input?: FrameInput, + ) => { __advanceClock(); __setAnalog(analog); - __setTouches(touches); + __setTouches(touches, hits); + __setFrameInput(input); __drainEffects(); + handlePointerInput(); // Octane schedules re-renders on the microtask queue; the sync boundary // drains them before the sweep so a frame's commits land in that frame. flushUniversalSync(() => { @@ -222,8 +232,10 @@ export function render(code: OctaneRenderRoot, opts: RenderOptions = {}): () => return () => { removeResizeViewportHook(); __resetTouches(); + __resetFrameInput(); dispose(); setInputRoot(null); + setHitRoot(null); setOverlayRoot(null); appLayer = null; overlayLayer = null; diff --git a/framework/src/index-vue-vapor.ts b/framework/src/index-vue-vapor.ts index 81a5590f..116ebfb0 100644 --- a/framework/src/index-vue-vapor.ts +++ b/framework/src/index-vue-vapor.ts @@ -27,9 +27,10 @@ import { } from "./renderer-vue-vapor.ts"; import { setOverlayRoot } from "./overlay.ts"; import { registerStyles, resolveStyle } from "./styles.ts"; -import { handleFrame, setInputRoot } from "./input.ts"; +import { handleFrame, handlePointerInput, setHitRoot, setInputRoot } from "./input.ts"; import { __setAnalog, resetFrameHooks, runFrameHooks } from "./frame-vue-vapor.ts"; import { __resetTouches, __setTouches } from "./touch.ts"; +import { __resetFrameInput, __setFrameInput, type FrameInput } from "./frame-input.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"; @@ -196,16 +197,25 @@ export function render(code: VaporRenderRoot, opts: RenderOptions = {}): () => v overlayLayer = overlayRoot; setInputRoot(appRoot); + setHitRoot(rootMirror); 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[], + hits?: readonly number[], + input?: FrameInput, + ) => { __advanceClock(); __setAnalog(analog); - __setTouches(touches); + __setTouches(touches, hits); + __setFrameInput(input); __drainEffects(); + handlePointerInput(); runFrameHooks(buttons); handleFrame(buttons); runSweep(); @@ -217,8 +227,10 @@ export function render(code: VaporRenderRoot, opts: RenderOptions = {}): () => v return () => { removeResizeViewportHook(); __resetTouches(); + __resetFrameInput(); dispose(); setInputRoot(null); + setHitRoot(null); setOverlayRoot(null); appLayer = null; overlayLayer = null; diff --git a/framework/src/index.ts b/framework/src/index.ts index 4972cd5b..0315cdb8 100644 --- a/framework/src/index.ts +++ b/framework/src/index.ts @@ -40,11 +40,12 @@ import { } from "./renderer.ts"; import { setOverlayRoot } from "./overlay.ts"; import { registerStyles, resolveStyle } from "./styles.ts"; -import { handleFrame, setHitRoot, setInputRoot } from "./input.ts"; +import { handleFrame, handlePointerInput, setHitRoot, setInputRoot } from "./input.ts"; import { __runGestures, resetGestures } from "./gesture.ts"; import { installTouchActivation } from "./touch-activation.ts"; import { __setAnalog, resetFrameHooks, runFrameHooks } from "./frame.ts"; import { __resetTouches, __setTouches } from "./touch.ts"; +import { __resetFrameInput, __setFrameInput, type FrameInput } from "./frame-input.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"; @@ -265,12 +266,20 @@ 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[], hits?: readonly number[]) => { + wrapFrameHandler(( + buttons: number, + analog: number, + touches?: readonly number[], + hits?: readonly number[], + input?: FrameInput, + ) => { __advanceClock(); // virtual frame++, fire due after() timers __setAnalog(analog); // latch the nub before any app code reads it __setTouches(touches, hits); // latch contacts + their host-resolved hit facts + __setFrameInput(input); // latch the versioned host-input extension __drainEffects(); // frame-boundary deliveries enter the world first __runGestures(); // contact lifecycles resolve before app hooks read them + handlePointerInput(); // hover/edges resolve before app hooks inspect focus runFrameHooks(buttons); // app lifecycle callbacks: onFrame/onButtonPress/etc. handleFrame(buttons); // edge-detect, focus nav, onPress (runs effects) runSweep(); // then destroy subtrees still detached [R] @@ -282,6 +291,7 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi return () => { removeResizeViewportHook(); __resetTouches(); + __resetFrameInput(); resetGestures(); dispose(); // tears down reactivity only — universal keeps the nodes setInputRoot(null); // drops focus state (native focus dies with the nodes) diff --git a/framework/src/input-api.ts b/framework/src/input-api.ts index 4fb5ddca..a36dc18d 100644 --- a/framework/src/input-api.ts +++ b/framework/src/input-api.ts @@ -2,6 +2,16 @@ export { BTN } from "../../contracts/spec/spec.ts"; export { touches, type TouchContact } from "./touch.ts"; +export { + FRAME_INPUT_VERSION, + POINTER_EVENT, + POINTER_MODIFIER, + pointerEvents, + type FrameInput, + type FrameInputV1, + type PointerEvent, + type PointerWireEvent, +} from "./frame-input.ts"; export { cursorX, cursorY, @@ -10,6 +20,7 @@ export { getFocused, hitFocusable, hitNode, + pointer, pressNode, pushFocusController, pushFocusGrid, @@ -19,4 +30,5 @@ export { type FocusDirection, type FocusGridOptions, type FocusScopeOptions, + type PointerSnapshot, } from "./input.ts"; diff --git a/framework/src/input.ts b/framework/src/input.ts index a1e40a1a..2f7d4422 100644 --- a/framework/src/input.ts +++ b/framework/src/input.ts @@ -34,10 +34,19 @@ // - 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). +// +// Real pointer mode (input.pointer capability, host-driven): +// - Ordered move/down/up/leave/cancel edges arrive in the versioned frame +// input payload. It neither enables nor renders the virtual cursor. +// - Hover is focus. Primary down arms the hovered node; release over that +// same node fires onPress. Leave lifts the active look but preserves the +// capture; cancel/blur clears it without firing. +// - DOWN then UP in one host tick is intentionally valid and fires once. 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 { pointerEvents, type PointerEvent } from "./frame-input.ts"; import { getHost, getOps, hostViewport, type HostOps } from "./host.ts"; import { get as pakGet } from "./pak.ts"; import type { NodeMirror } from "./renderer.ts"; @@ -50,6 +59,33 @@ const focusScopeStack: NodeMirror[] = []; const focusGridStack: FocusGridRegistration[] = []; const focusControllerStack: FocusControllerRegistration[] = []; +export interface PointerSnapshot { + readonly x: number; + readonly y: number; + readonly down: boolean; +} + +interface RealPointerState { + x: number; + y: number; + present: boolean; + primaryDown: boolean; + pressTarget: NodeMirror | null; + target: NodeMirror | null; + gen: number; +} + +const realPointer: RealPointerState = { + x: 0, + y: 0, + present: false, + primaryDown: false, + pressTarget: null, + target: null, + gen: -1, +}; +let handledPointerBatch: readonly PointerEvent[] = pointerEvents(); + /** Bind the focus manager to a mirror tree root (index.ts render()). The * cursor SURVIVES a rebind (enableCursor at module top runs before mount); * its sprite is released and re-arms so the next frame re-uploads. The @@ -65,6 +101,12 @@ export function setInputRoot(r: NodeMirror | null): void { focusScopeStack.length = 0; focusGridStack.length = 0; focusControllerStack.length = 0; + realPointer.present = false; + realPointer.primaryDown = false; + realPointer.pressTarget = null; + realPointer.target = null; + realPointer.gen = -1; + handledPointerBatch = pointerEvents(); if (cursor) { cursor.pressTarget = null; cursor.target = null; @@ -126,6 +168,13 @@ export function getFocused(): NodeMirror | null { return focused; } +/** Current real-pointer position, or null after leave/cancel/before entry. */ +export function pointer(): PointerSnapshot | null { + return realPointer.present + ? Object.freeze({ x: realPointer.x, y: realPointer.y, down: realPointer.primaryDown }) + : null; +} + function activeFocusRoot(): NodeMirror | null { return focusScopeStack.length > 0 ? focusScopeStack[focusScopeStack.length - 1] : root; } @@ -735,6 +784,119 @@ export function resolveTouchHit( return findMirror(hitRoot ?? root, query(x, y)); } +function pointerPoint(x: number, y: number): [number, number] { + // Read the viewport for every delivered event. Desktop hosts update + // __viewport from the live resize callback, so pointer bounds never retain + // the virtual cursor's old cached dimensions. + const vp = hostViewport(getOps()); + const w = Math.max(1, vp?.w ?? SCREEN_W); + const h = Math.max(1, vp?.h ?? SCREEN_H); + return [Math.min(Math.max(x, 0), w - 1), Math.min(Math.max(y, 0), h - 1)]; +} + +function realPointerHit(x: number, y: number): NodeMirror | null { + const ops = getOps(); + if (!ops.hitTest) return null; + return cursorTarget(findMirror(hitRoot ?? root, ops.hitTest(x, y))); +} + +function pointRealPointer(event: Extract): NodeMirror | null { + const [x, y] = pointerPoint(event.x, event.y); + realPointer.x = x; + realPointer.y = y; + realPointer.present = true; + realPointer.target = realPointerHit(x, y); + realPointer.gen = inputGen; + if (realPointer.target !== focused) focusNode(realPointer.target); + return realPointer.target; +} + +/** + * Consume the current frame's ordered real-pointer batch once. Runtimes call + * this before app frame hooks so pointerEvents(), pointer(), and focus agree; + * handleFrame calls it too for direct/unit-test users. + */ +export function handlePointerInput(): boolean { + const events = pointerEvents(); + if (events === handledPointerBatch) { + if (!realPointer.present || realPointer.gen === inputGen) return false; + // A component/style/scope change under a parked pointer can change the + // hover answer without a host motion event (for example a menu remount). + realPointer.gen = inputGen; + realPointer.target = realPointerHit(realPointer.x, realPointer.y); + if (realPointer.target !== focused) focusNode(realPointer.target); + if (realPointer.pressTarget) { + setPressedNode( + realPointer.primaryDown && realPointer.target === realPointer.pressTarget + ? realPointer.pressTarget + : null, + ); + } + return true; + } + handledPointerBatch = events; + if (events.length === 0) return false; + + for (const event of events) { + if (event.type === "leave") { + realPointer.present = false; + realPointer.target = null; + setPressedNode(null); + if (focused) focusNode(null); + continue; + } + if (event.type === "cancel") { + realPointer.present = false; + realPointer.primaryDown = false; + realPointer.pressTarget = null; + realPointer.target = null; + setPressedNode(null); + if (focused) focusNode(null); + continue; + } + + const target = pointRealPointer(event); + if (event.type === "move") { + if (realPointer.pressTarget) { + setPressedNode( + realPointer.primaryDown && target === realPointer.pressTarget + ? realPointer.pressTarget + : null, + ); + } + continue; + } + if (event.button !== 0) continue; + + if (event.type === "down") { + // A malformed duplicate down cannot strand the previous capture. + if (realPointer.pressTarget) setPressedNode(null); + realPointer.primaryDown = true; + realPointer.pressTarget = target; + setPressedNode(target); + continue; + } + + // Explicit UP is the only successful completion edge. A cancel never + // travels this branch and therefore can never synthesize onPress. + realPointer.primaryDown = false; + const captured = realPointer.pressTarget; + const fire = captured !== null && target === captured; + realPointer.pressTarget = null; + setPressedNode(null); + if (fire) firePressFrom(captured); + } + // onPress may synchronously remount the node under a stationary pointer. + if (realPointer.present && realPointer.gen !== inputGen) { + realPointer.gen = inputGen; + realPointer.target = realPointerHit(realPointer.x, realPointer.y); + if (realPointer.target !== focused) focusNode(realPointer.target); + } else { + realPointer.gen = inputGen; + } + return true; +} + /** One cursor-mode frame. Returns false when the host predates the cursor * ops — the caller then falls through to the classic d-pad model, so a * stale host never loses input. */ @@ -825,7 +987,13 @@ export function handleFrame(buttons: number): void { const pressed = buttons & ~prevButtons; const released = prevButtons & ~buttons; prevButtons = buttons; - if (cursor && cursorFrame(buttons, pressed, released)) return; + const realPointerHandled = handlePointerInput(); + if (cursor) { + // The physical pointer and the virtual cursor are independent input + // paths. When both happen to be enabled, the device that delivered an + // event owns this frame; no sprite operation is required by the real one. + if (realPointerHandled || cursorFrame(buttons, pressed, released)) return; + } if (released & BTN.CIRCLE) setPressedNode(null); if (pressed === 0) return; if (pressed & BTN.DOWN) moveFocus("down"); diff --git a/hosts/sim/sim.ts b/hosts/sim/sim.ts index 30c7bc6a..1fefb661 100644 --- a/hosts/sim/sim.ts +++ b/hosts/sim/sim.ts @@ -29,6 +29,7 @@ import { join, resolve } from "node:path"; import { createWasmUi } from "../web/wasm-ops.js"; import { normalizeHz, TICKS_PER_SECOND } from "../../framework/src/clock.ts"; import { createTouchHitFacts, __packTouch } from "../../framework/src/touch.ts"; +import type { FrameInput } from "../../framework/src/frame-input.ts"; const ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url))); // PocketJS/ const DIST = join(ROOT, "dist/"); @@ -191,9 +192,13 @@ function ensureBuilt(path: string, cmd: string[]): void { let wasmBytes: ArrayBuffer | null = null; export interface SimWorld { - /** One host frame: buttons bitmask, analog byte, packed touch contacts - * (framework/src/touch.ts __packTouch format) — exactly the native frame() shape. */ - frame: (buttons: number, analog?: number, touches?: readonly number[]) => void; + /** One host frame: legacy tracks plus the versioned input extension. */ + frame: ( + buttons: number, + analog?: number, + touches?: readonly number[], + input?: FrameInput, + ) => void; tick: () => void; render: () => Uint8Array; ticksPerFrame: number; @@ -253,7 +258,13 @@ export async function bootWorld( const src = await Bun.file(DIST + app + ".js").text(); (0, eval)(src); const appFrame = g.frame as - | ((buttons: number, analog?: number, touches?: readonly number[], hits?: readonly number[]) => void) + | (( + buttons: number, + analog?: number, + touches?: readonly number[], + hits?: readonly number[], + input?: FrameInput, + ) => void) | undefined; if (typeof appFrame !== "function") { throw new Error("sim: bundle did not install globalThis.frame (entry must call render()/mount())"); @@ -264,8 +275,12 @@ export async function bootWorld( const hitTestBounds = (wasm.ops as { hitTestBounds?: (x: number, y: number) => number }) .hitTestBounds; const hitFacts = hitTestBounds ? createTouchHitFacts(hitTestBounds) : undefined; - const frame = (buttons: number, analog?: number, touches?: readonly number[]): void => - appFrame(buttons, analog, touches, hitFacts?.(touches)); + const frame = ( + buttons: number, + analog?: number, + touches?: readonly number[], + input?: FrameInput, + ): void => appFrame(buttons, analog, touches, hitFacts?.(touches), input); return { frame, tick: wasm.tick, diff --git a/tests/devtools.test.ts b/tests/devtools.test.ts index cafa15fe..134934e1 100644 --- a/tests/devtools.test.ts +++ b/tests/devtools.test.ts @@ -15,7 +15,18 @@ if (Bun.resolveSync("solid-js", import.meta.dir).endsWith("server.js")) { import { installHost, type Host, type HostOps } from "../framework/src/host.ts"; import { render as publicRender } from "../framework/src/index.ts"; -import { expandTape, expandTapeTouch, fmt, type Tape } from "../framework/src/devtools.ts"; +import { + expandTape, + expandTapeInput, + expandTapeTouch, + fmt, + type Tape, +} from "../framework/src/devtools.ts"; +import { + POINTER_EVENT, + pointerEvents, + type FrameInput, +} from "../framework/src/frame-input.ts"; import { touches, __packTouch } from "../framework/src/touch.ts"; import { onFrame } from "../framework/src/lifecycle.ts"; import { @@ -423,6 +434,98 @@ describe("tape v2 touch track", () => { }); }); +describe("tape v3 frame-input track", () => { + function frameInput(input?: FrameInput): void { + ( + globalThis as { + frame?: ( + b: number, + a?: number, + t?: readonly number[], + h?: readonly number[], + input?: FrameInput, + ) => void; + } + ).frame!(0, undefined, undefined, undefined, input); + } + + test("pointer edge batches export sparsely and preserve fast-click order", () => { + mountApp(() => View({})); + frameInput(); + frameInput({ + v: 1, + pointer: [ + [POINTER_EVENT.DOWN, 4095, 3072], + [POINTER_EVENT.UP, 4095, 3072], + ], + }); + frameInput({ v: 1, pointer: [[POINTER_EVENT.CANCEL]] }); + push({ t: "dumpTape" }); + frameInput(); + const tape = sent("tape")[0].tape as Tape; + expect(tape.v).toBe(3); + expect(tape.input).toEqual([ + [ + 1, + { + v: 1, + pointer: [ + [POINTER_EVENT.DOWN, 4095, 3072, 0, 0], + [POINTER_EVENT.UP, 4095, 3072, 0, 0], + ], + }, + ], + [2, { v: 1, pointer: [[POINTER_EVENT.CANCEL]] }], + ]); + expect(expandTapeInput(tape)[1]).toEqual(tape.input![0][1]); + }); + + test("v3 replay restores pointer events and scrubs live pointer hardware", () => { + const seen: string[][] = []; + mountApp(() => { + onFrame(() => seen.push(pointerEvents().map((event) => event.type))); + return View({}); + }); + const tape: Tape = { + v: 3, + frames: 3, + masks: [[0, 3]], + input: [ + [ + 1, + { + v: 1, + pointer: [ + [POINTER_EVENT.DOWN, 10, 20], + [POINTER_EVENT.UP, 10, 20], + ], + }, + ], + ], + }; + push({ t: "replay", tape }); + const live: FrameInput = { v: 1, pointer: [[POINTER_EVENT.MOVE, 999, 999]] }; + frameInput(live); + frameInput(live); + frameInput(live); + frameInput(live); + expect(seen).toEqual([[], ["down", "up"], [], ["move"]]); + }); + + test("old tapes replay every frame with no versioned input", () => { + const seen: number[] = []; + mountApp(() => { + onFrame(() => seen.push(pointerEvents().length)); + return View({}); + }); + push({ t: "replay", tape: { v: 1, frames: 2, masks: [[0, 2]] } satisfies Tape }); + const live: FrameInput = { v: 1, pointer: [[POINTER_EVENT.MOVE, 1, 1]] }; + frameInput(live); + frameInput(live); + expect(seen).toEqual([0, 0]); + }); +}); + describe("errors + formatting", () => { test("a throwing frame reports to the channel and still rethrows", () => { let boom = false; diff --git a/tests/pointer-frameworks.test.ts b/tests/pointer-frameworks.test.ts new file mode 100644 index 00000000..8602eef4 --- /dev/null +++ b/tests/pointer-frameworks.test.ts @@ -0,0 +1,46 @@ +// The same one-tick real-pointer click crosses each framework entrypoint. + +import { describe, expect, test } from "bun:test"; + +import { POINTER_EVENT } from "../framework/src/frame-input.ts"; +import { bootWorld, type SimWorld } from "../hosts/sim/sim.ts"; + +function textOf(value: unknown): string { + if (!value || typeof value !== "object") return ""; + const node = value as { x?: unknown; k?: unknown[] }; + let text = node.x === undefined ? "" : String(node.x); + for (const child of node.k ?? []) text += textOf(child); + return text; +} + +async function settle(world: SimWorld): Promise { + for (let frame = 0; frame < 4; frame++) { + world.frame(0); + world.tick(); + await Promise.resolve(); + } +} + +describe("pointer frame input framework parity", () => { + for (const app of [ + "hero-main", + "hero-vue-vapor-main.vue-vapor", + "hero-main.octane", + ]) { + test(`${app} handles hover + fast click`, async () => { + const world = await bootWorld(app, 60); + await settle(world); + world.frame(0, undefined, undefined, { + v: 1, + pointer: [ + [POINTER_EVENT.MOVE, 80, 220], + [POINTER_EVENT.DOWN, 80, 220], + [POINTER_EVENT.UP, 80, 220], + ], + }); + world.tick(); + await Promise.resolve(); + expect(textOf(world.getTree())).toContain("Count: 1"); + }); + } +}); diff --git a/tests/pointer-input.test.ts b/tests/pointer-input.test.ts new file mode 100644 index 00000000..5c935683 --- /dev/null +++ b/tests/pointer-input.test.ts @@ -0,0 +1,187 @@ +// Real pointer contract (input.pointer): ordered frame-input edges drive +// hover/focus/activation without enabling or drawing input.cursor. + +import { beforeEach, describe, expect, test } from "bun:test"; + +import { + __resetFrameInput, + __setFrameInput, + POINTER_EVENT, + pointerEvents, + type PointerWireEvent, +} from "../framework/src/frame-input.ts"; +import { installHost, type Host, type HostOps } from "../framework/src/host.ts"; +import { + getFocused, + handlePointerInput, + pointer, + resetInput, + setInputRoot, +} from "../framework/src/input.ts"; +import type { NodeMirror } from "../framework/src/renderer.ts"; +import { NODE_TYPE, ROOT_ID } from "../contracts/spec/spec.ts"; + +type Call = [string, ...unknown[]]; + +interface PointerHost extends Host { + calls: Call[]; + hitResult: number; +} + +function makeHost(): PointerHost { + const calls: Call[] = []; + const host: PointerHost = { + kind: "injected", + target: "test", + strict: true, + calls, + hitResult: 0, + ops: {} as HostOps, + }; + const rec = (name: string) => (...args: unknown[]) => calls.push([name, ...args]); + host.ops = { + createNode: () => 0, + destroyNode: rec("destroyNode"), + insertBefore: rec("insertBefore"), + removeChild: rec("removeChild"), + setStyle: rec("setStyle"), + setProp: rec("setProp"), + setText: rec("setText"), + replaceText: rec("replaceText"), + uploadTexture: () => 1, + setImage: rec("setImage"), + setSprite: rec("setSprite"), + animate: () => 1, + cancelAnim: rec("cancelAnim"), + setFocus: rec("setFocus"), + setActive: rec("setActive"), + setCursor: rec("setCursor"), + setCursorPos: rec("setCursorPos"), + hitTest(x, y) { + calls.push(["hitTest", x, y]); + return host.hitResult; + }, + measureText: () => 0, + }; + (host.ops as HostOps & { __viewport: { w: number; h: number } }).__viewport = { + w: 4096, + h: 4096, + }; + return host; +} + +function mk(id: number, parent: NodeMirror | null, extra: Partial = {}): NodeMirror { + const node: NodeMirror = { id, type: NODE_TYPE.view, parent, children: [], ...extra }; + if (parent) parent.children.push(node); + return node; +} + +let host: PointerHost; +let root: NodeMirror; + +beforeEach(() => { + host = makeHost(); + installHost(host); + __resetFrameInput(); + resetInput(); + root = mk(ROOT_ID, null); + setInputRoot(root); +}); + +function frame(events: readonly PointerWireEvent[]): void { + __setFrameInput({ v: 1, pointer: events }); + handlePointerInput(); +} + +describe("versioned frame input", () => { + test("keeps high-resolution coordinates and immutable ordered edges", () => { + frame([ + [POINTER_EVENT.MOVE, 4095, 3072], + [POINTER_EVENT.DOWN, 4095, 3072], + [POINTER_EVENT.UP, 4095, 3072], + ]); + expect(pointerEvents().map((event) => event.type)).toEqual(["move", "down", "up"]); + expect(Object.isFrozen(pointerEvents())).toBe(true); + expect(pointer()).toEqual({ x: 4095, y: 3072, down: false }); + }); + + test("unsupported versions and malformed coordinates deliver no events", () => { + __setFrameInput({ v: 2, pointer: [[POINTER_EVENT.MOVE, 1, 2]] } as never); + expect(pointerEvents()).toEqual([]); + __setFrameInput({ v: 1, pointer: [[POINTER_EVENT.MOVE, Number.NaN, 2]] }); + expect(pointerEvents()).toEqual([]); + }); +}); + +describe("framework-owned interaction", () => { + test("hover focuses without allocating or moving a virtual cursor sprite", () => { + const button = mk(10, root, { focusable: true }); + host.hitResult = button.id; + frame([[POINTER_EVENT.MOVE, 120, 80]]); + expect(getFocused()).toBe(button); + expect(host.calls.filter(([name]) => name === "setCursor" || name === "setCursorPos")).toEqual([]); + }); + + test("a complete fast click in one tick fires onPress exactly once", () => { + let presses = 0; + const button = mk(11, root, { focusable: true, onPress: () => presses++ }); + host.hitResult = button.id; + frame([ + [POINTER_EVENT.DOWN, 50, 40], + [POINTER_EVENT.UP, 50, 40], + ]); + expect(presses).toBe(1); + expect(host.calls.filter(([name]) => name === "setActive")).toEqual([ + ["setActive", button.id, 1], + ["setActive", button.id, 0], + ]); + }); + + test("drag-away release cancels, while re-enter before release succeeds", () => { + let presses = 0; + const button = mk(12, root, { focusable: true, onPress: () => presses++ }); + const other = mk(13, root, { focusable: true }); + host.hitResult = button.id; + frame([[POINTER_EVENT.DOWN, 10, 10]]); + host.hitResult = other.id; + frame([[POINTER_EVENT.MOVE, 90, 90], [POINTER_EVENT.UP, 90, 90]]); + expect(presses).toBe(0); + + host.hitResult = button.id; + frame([[POINTER_EVENT.DOWN, 10, 10]]); + host.hitResult = other.id; + frame([[POINTER_EVENT.MOVE, 90, 90]]); + host.hitResult = button.id; + frame([[POINTER_EVENT.MOVE, 10, 10], [POINTER_EVENT.UP, 10, 10]]); + expect(presses).toBe(1); + }); + + test("leave clears hover and cancel clears capture without firing", () => { + let presses = 0; + const button = mk(14, root, { focusable: true, onPress: () => presses++ }); + host.hitResult = button.id; + frame([[POINTER_EVENT.DOWN, 20, 20]]); + frame([[POINTER_EVENT.LEAVE]]); + expect(getFocused()).toBeNull(); + expect(pointer()).toBeNull(); + frame([[POINTER_EVENT.CANCEL]]); + frame([[POINTER_EVENT.UP, 20, 20]]); + expect(presses).toBe(0); + }); + + test("reads the live viewport after resize instead of caching old bounds", () => { + frame([[POINTER_EVENT.MOVE, 4000, 3000]]); + expect(pointer()).toEqual({ x: 4000, y: 3000, down: false }); + (host.ops as HostOps & { __viewport: { w: number; h: number } }).__viewport = { + w: 320, + h: 180, + }; + frame([[POINTER_EVENT.MOVE, 4000, 3000]]); + expect(pointer()).toEqual({ x: 319, y: 179, down: false }); + expect(host.calls.filter(([name]) => name === "hitTest").at(-1)).toEqual([ + "hitTest", + 319, + 179, + ]); + }); +}); diff --git a/tools/tape.ts b/tools/tape.ts index d4964dc1..a61ef930 100644 --- a/tools/tape.ts +++ b/tools/tape.ts @@ -22,10 +22,12 @@ import { createWasmUi } from "../hosts/web/wasm-ops.js"; import { expandTape, expandTapeAnalog, + expandTapeInput, expandTapeTouch, type Tape, } from "../framework/src/devtools.ts"; import { __packTouch } from "../framework/src/touch.ts"; +import type { FrameInput } from "../framework/src/frame-input.ts"; import { encodePNG } from "../tests/png.ts"; import { SCREEN_H, SCREEN_W } from "../contracts/spec/spec.ts"; @@ -76,7 +78,13 @@ function fnv1a(bytes: Uint8Array): string { } interface BootResult { - frame: (buttons: number, analog?: number, touches?: readonly number[]) => void; + frame: ( + buttons: number, + analog?: number, + touches?: readonly number[], + hits?: readonly number[], + input?: FrameInput, + ) => void; tick: () => void; render: () => Uint8Array; outbox: string[]; @@ -147,6 +155,7 @@ async function cmdReplay(): Promise { const masks = expandTape(tape); const analogs = expandTapeAnalog(tape); const touches = expandTapeTouch(tape); + const inputs = expandTapeInput(tape); const hashesOut = argValue("--hashes"); const assertPath = argValue("--assert"); const pngFrames = new Set( @@ -161,7 +170,7 @@ async function cmdReplay(): Promise { if (pngFrames.size) mkdirSync(outdir, { recursive: true }); const hashes: string[] = []; for (let f = 0; f < masks.length; f++) { - b.frame(masks[f], analogs[f], touches[f]); + b.frame(masks[f], analogs[f], touches[f], undefined, inputs[f]); b.tick(); const fb = b.render(); const h = fnv1a(fb); @@ -199,11 +208,12 @@ async function cmdTree(): Promise { const masks = expandTape(tape); const analogs = expandTapeAnalog(tape); const touches = expandTapeTouch(tape); + const inputs = expandTapeInput(tape); const at = Number(argValue("--at") ?? masks.length); const upTo = Math.min(at, masks.length); const b = await boot(app); for (let f = 0; f < upTo; f++) { - b.frame(masks[f], analogs[f], touches[f]); + b.frame(masks[f], analogs[f], touches[f], undefined, inputs[f]); b.tick(); } b.outbox.length = 0; diff --git a/tools/test.ts b/tools/test.ts index 82bd6dd2..65614cfd 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -82,6 +82,7 @@ const SUITE: readonly Stage[] = [ "tests/touch-activation.test.ts", "tests/portal-hit.test.ts", "tests/cursor.test.ts", + "tests/pointer-input.test.ts", "tests/action-handler-vue-vapor.test.ts", "tests/vue-vapor-dom.test.ts", "tests/vue-vapor-pak.test.ts", @@ -92,6 +93,16 @@ const SUITE: readonly Stage[] = [ "tests/tiles.test.ts", ], }, + { + name: "pointer framework parity", + prep: [ + ["bun", "tools/build.ts", "hero-main"], + ["bun", "tools/build.ts", "hero-vue-vapor-main", "--framework=vue-vapor"], + ["bun", "tools/build.ts", "hero-main", "--framework=octane"], + ], + browser: true, + tests: ["tests/pointer-frameworks.test.ts"], + }, { name: "vue-sfc journeys", prep: [