diff --git a/contracts/spec/spec.ts b/contracts/spec/spec.ts index 39f74ebc..8257bb5c 100644 --- a/contracts/spec/spec.ts +++ b/contracts/spec/spec.ts @@ -225,6 +225,22 @@ export const OP = { // undo it; consoles are 480x272). // Valid inside the summoned launcher guest until the // next switch; -1 otherwise. + // -- touch hit facts (input.touch capability; docs/TOUCH.md) --------------- + hitTestBounds: 42, // (x: f32, y: f32) -> topmost node id at that logical + // point by LAYOUT BOX alone, or 0. The same paint-order + // walk as hitTest (clips, transforms, opacity culling, + // display:none) minus the paints-something requirement: + // pure layout containers claim their box (UIKit bounds + // semantics — a finger in a list's row gap still owns + // the list). This is the cold-path QUERY form of the + // touch hit FACT: a host with input.touch resolves it + // once per contact at the DOWN edge against the + // committed frame, carries it for the contact's + // lifetime, and delivers it as frame() argument 4 + // (`hits`, parallel to `touches`; see the frame + // contract note on that argument). The guest only + // issues this op when no fact channel exists (devtools + // replay, injected test hosts, older wasm builds). } as const; // --------------------------------------------------------------------------- @@ -269,6 +285,12 @@ export const PROP = { display: 29, // enum Display overflow: 30, // enum Overflow (hidden => scissor in draw) zIndex: 31, // i32 (paint order among siblings; layout-group id but paint-only) + hitPass: 32, // 0|1. 1 = the node's OWN box never claims a hit (ink or + // bounds walk alike); descendants are still tested — the + // engine's pointer-events:none, self-only. The framework + // marks its full-screen overlay/portal layers with it so + // bounds hit facts (op 42) resolve through empty overlay + // space to the app content beneath. // -- visual (64..95) ------------------------------------------------------- bgColor: 64, // color u32 ABGR @@ -446,6 +468,7 @@ export const PROP_VALUE_KIND: Record = { insetT: VALUE_KIND.f32, insetR: VALUE_KIND.f32, insetB: VALUE_KIND.f32, insetL: VALUE_KIND.f32, display: VALUE_KIND.int, overflow: VALUE_KIND.int, zIndex: VALUE_KIND.int, + hitPass: VALUE_KIND.int, bgColor: VALUE_KIND.color, gradFrom: VALUE_KIND.color, gradTo: VALUE_KIND.color, gradDir: VALUE_KIND.int, radius: VALUE_KIND.f32, opacity: VALUE_KIND.f32, borderColor: VALUE_KIND.color, borderWidth: VALUE_KIND.f32, diff --git a/engine/core/src/draw.rs b/engine/core/src/draw.rs index 667f4fb7..a38d08c3 100644 --- a/engine/core/src/draw.rs +++ b/engine/core/src/draw.rs @@ -697,9 +697,23 @@ fn claims_hit( /// hit beats clicking whatever sits BEHIND visible 3D content. /// Returns the generation-tagged id, or 0. pub fn hit_test(tree: &Tree, styles: &StyleTable, screen: (f32, f32), x: f32, y: f32) -> i32 { + hit_point(tree, styles, screen, x, y, true) +} + +/// Topmost node at a logical point by LAYOUT BOX alone (spec op +/// hitTestBounds; the touch hit FACT resolver). The identical walk minus the +/// `claims_hit` ink requirement: pure layout containers claim their box, so +/// a finger in a list's row gap still resolves to the list — UIKit bounds +/// semantics. Everything else (paint order, clips, transforms, opacity +/// culling, 3D contexts) matches `hit_test` exactly. +pub fn hit_test_bounds(tree: &Tree, styles: &StyleTable, screen: (f32, f32), x: f32, y: f32) -> i32 { + hit_point(tree, styles, screen, x, y, false) +} + +fn hit_point(tree: &Tree, styles: &StyleTable, screen: (f32, f32), x: f32, y: f32, ink: bool) -> i32 { let root_slot = crate::tree::split_id(spec::ROOT_ID).1; let mut hit = 0i32; - hit_walk(tree, styles, screen, root_slot, Affine::IDENTITY, 1.0, Clip::viewport(screen), x, y, &mut hit); + hit_walk(tree, styles, screen, root_slot, Affine::IDENTITY, 1.0, Clip::viewport(screen), x, y, ink, &mut hit); hit } @@ -714,6 +728,7 @@ fn hit_walk( clip: Clip, px: f32, py: f32, + ink: bool, hit: &mut i32, ) { // The point is fixed, so a clip that excludes it excludes the node AND @@ -735,9 +750,9 @@ fn hit_walk( } let local = local_point(&world, px, py); let inside = local.is_some_and(|(lx, ly)| lx >= 0.0 && lx < l.w && ly >= 0.0 && ly < l.h); - if inside { + if inside && r.hit_pass == 0 { let (lx, ly) = local.unwrap(); - if claims_hit(node, &r, styles, lx, ly, l.w, l.h) { + if !ink || claims_hit(node, &r, styles, lx, ly, l.w, l.h) { *hit = node.id(slot); } } @@ -756,13 +771,13 @@ fn hit_walk( // 3D context: projected geometry is not point-testable from the 2D // walk — the context root claims its own box so clicks never fall // through to content painted BEHIND the visible 3D subtree. - if inside { + if inside && r.hit_pass == 0 { *hit = node.id(slot); } return; } for_children_in_paint_order(tree, styles, slot, |cs| { - hit_walk(tree, styles, screen, cs, world, op, child_clip, px, py, hit); + hit_walk(tree, styles, screen, cs, world, op, child_clip, px, py, ink, hit); }); } diff --git a/engine/core/src/lib.rs b/engine/core/src/lib.rs index 0c02cdc0..fd71ee68 100644 --- a/engine/core/src/lib.rs +++ b/engine/core/src/lib.rs @@ -43,6 +43,7 @@ pub mod stream; pub mod stream_rx; pub mod style; pub mod text; +pub mod touch; pub mod tree; pub mod wire; @@ -247,6 +248,8 @@ pub struct Ui { cursor_hot: (f32, f32), cursor_size: (f32, f32), cursor_pos: (f32, f32), + /// Per-contact hit-at-down carry (touch hit facts; `touch_hits`). + touch_table: touch::HitTable, /// Frame counter advanced by `tick()` (drives fixed-dt animation). frame: u64, /// DevTools (spec ops 18..22, docs/DEVTOOLS.md). All default-off. @@ -300,6 +303,7 @@ impl Ui { cursor_hot: (0.0, 0.0), cursor_size: (0.0, 0.0), cursor_pos: (0.0, 0.0), + touch_table: touch::HitTable::default(), frame: 0, inspect_id: 0, inspect_rect: None, @@ -842,6 +846,60 @@ impl Ui { draw::hit_test(&self.tree, &self.styles, self.layout.viewport, x, y) } + /// `hit_test`'s bounds-only twin (spec op hitTestBounds): pure layout + /// containers claim their box — the touch hit FACT resolver (see + /// draw::hit_test_bounds). Same relayout-if-dirty rule. + pub fn hit_test_bounds(&mut self, x: f32, y: f32) -> i32 { + if self.layout.needs() { + layout::relayout(&mut self.tree, &self.styles, &self.fonts, &mut self.layout); + } + draw::hit_test_bounds(&self.tree, &self.styles, self.layout.viewport, x, y) + } + + /// Resolve the touch hit facts for this frame's packed contacts (frame() + /// argument 4; docs/TOUCH.md). A NEW contact id is bounds-hit ONCE + /// against the committed layout and the node id is carried until the id + /// lifts — hosts call this right before the guest frame, so the guest + /// never issues a hit query on the touch path. Returns the number of + /// entries written to `out` (parallel to `packed`, capped at 8). + pub fn touch_hits(&mut self, packed: &[u32], out: &mut [i32; 8]) -> usize { + let n = packed.len().min(8); + let mut seen = [false; 8]; + for i in 0..n { + let (id, x, y) = touch::decode(packed[i]); + let mut carried = None; + for s in 0..8 { + if self.touch_table.live[s] && self.touch_table.ids[s] == id { + carried = Some(self.touch_table.hits[s]); + seen[s] = true; + break; + } + } + out[i] = match carried { + Some(h) => h, + None => { + let h = self.hit_test_bounds(x, y); + for s in 0..8 { + if !self.touch_table.live[s] { + self.touch_table.live[s] = true; + self.touch_table.ids[s] = id; + self.touch_table.hits[s] = h; + seen[s] = true; + break; + } + } + h + } + }; + } + for s in 0..8 { + if self.touch_table.live[s] && !seen[s] { + self.touch_table.live[s] = false; + } + } + n + } + /// Bind the virtual cursor sprite (spec op setCursor): an uploaded /// texture drawn last every frame at the cursor position, offset by /// (hot_x, hot_y). tex < 0 or a stale handle hides the cursor; w/h <= 0 diff --git a/engine/core/src/spec.rs b/engine/core/src/spec.rs index 08b1687e..af33f1a4 100644 --- a/engine/core/src/spec.rs +++ b/engine/core/src/spec.rs @@ -94,6 +94,7 @@ pub mod op { pub const APP_TABLE: u8 = 39; pub const APP_LAUNCH: u8 = 40; pub const APP_SHOT: u8 = 41; + pub const HIT_TEST_BOUNDS: u8 = 42; } /// Property ids (u8, stable, append-only). Groups: @@ -129,6 +130,7 @@ pub mod prop { pub const DISPLAY: u8 = 29; pub const OVERFLOW: u8 = 30; pub const Z_INDEX: u8 = 31; + pub const HIT_PASS: u8 = 32; pub const BG_COLOR: u8 = 64; pub const GRAD_FROM: u8 = 65; pub const GRAD_TO: u8 = 66; @@ -176,7 +178,7 @@ pub mod value_kind { pub const PROP_VALUE_KIND: [u8; 256] = [ 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x02, 0x02, 0x00, 0x00, 0x00, 0x02, 0x02, 0x00, 0x00, 0x00, 0x00, 0x02, 0x02, 0x02, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x02, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, 0x01, 0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x02, 0xff, 0xff, 0xff, 0xff, 0x01, 0x01, 0x01, 0x01, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, diff --git a/engine/core/src/style.rs b/engine/core/src/style.rs index 7145d83b..2c0338d8 100644 --- a/engine/core/src/style.rs +++ b/engine/core/src/style.rs @@ -257,6 +257,7 @@ pub struct Resolved { pub inset: [f32; 4], pub display: u8, pub overflow: u8, + pub hit_pass: u8, pub z_index: i32, pub bg_color: u32, pub grad_from: u32, @@ -326,6 +327,7 @@ impl Default for Resolved { inset: [f32::NAN; 4], display: spec::Display::Flex as u8, overflow: spec::Overflow::Visible as u8, + hit_pass: 0, z_index: 0, bg_color: 0, grad_from: 0, @@ -400,6 +402,7 @@ impl Resolved { p::INSET_L => self.inset[3] = f, p::DISPLAY => self.display = bits as u8, p::OVERFLOW => self.overflow = bits as u8, + p::HIT_PASS => self.hit_pass = bits as u8, p::Z_INDEX => self.z_index = bits as i32, p::BG_COLOR => self.bg_color = bits, p::GRAD_FROM => self.grad_from = bits, @@ -473,6 +476,7 @@ impl Resolved { p::INSET_L => self.inset[3].to_bits(), p::DISPLAY => self.display as u32, p::OVERFLOW => self.overflow as u32, + p::HIT_PASS => self.hit_pass as u32, p::Z_INDEX => self.z_index as u32, p::BG_COLOR => self.bg_color, p::GRAD_FROM => self.grad_from, diff --git a/engine/core/src/tests.rs b/engine/core/src/tests.rs index f42b0b4e..ec7a45a9 100644 --- a/engine/core/src/tests.rs +++ b/engine/core/src/tests.rs @@ -3240,3 +3240,90 @@ fn ram_stream_reconstructs_the_committed_golden() { image[a + 20..a + 24].copy_from_slice(&final_a.to_le_bytes()); assert_eq!(&image[..], golden, "socket-fed RAM ring == TS-written file, byte for byte"); } + +// --------------------------------------------------------------------------- +// touch hit facts (spec op hitTestBounds + Ui::touch_hits; docs/TOUCH.md) +// --------------------------------------------------------------------------- + +#[test] +fn hit_test_bounds_claims_pure_layout_containers() { + let mut ui = Ui::new(); + // An unstyled container (a list viewport): ink-transparent, bounds-solid. + let viewport = ui.create_node(0); + ui.set_prop(viewport, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop(viewport, spec::prop::INSET_L, 10.0); + ui.set_prop(viewport, spec::prop::INSET_T, 10.0); + ui.set_prop(viewport, spec::prop::WIDTH, 100.0); + ui.set_prop(viewport, spec::prop::HEIGHT, 100.0); + ui.insert_before(spec::ROOT_ID, viewport, 0); + let row = abs_box(&mut ui, viewport, 0.0, 0.0, 100.0, 20.0); + ui.tick(); + // On the painted row both modes agree (paint order, depth, clips shared). + assert_eq!(ui.hit_test(20.0, 15.0), row); + assert_eq!(ui.hit_test_bounds(20.0, 15.0), row); + // In the row gap: ink misses, bounds resolves to the container's box — + // the property that lets a list own its whole viewport without painted + // rows under every finger (the touchRect workaround this replaces). + assert_eq!(ui.hit_test(20.0, 80.0), 0, "ink: nothing painted in the gap"); + assert_eq!(ui.hit_test_bounds(20.0, 80.0), viewport, "bounds: the gap is the viewport's box"); +} + +#[test] +fn touch_hit_facts_carry_from_the_down_frame() { + let mut ui = Ui::new(); + let a = abs_box(&mut ui, spec::ROOT_ID, 0.0, 0.0, 50.0, 50.0); + let b = abs_box(&mut ui, spec::ROOT_ID, 100.0, 0.0, 50.0, 50.0); + ui.tick(); + let pack = |id: u32, x: u32, y: u32| (id << 18) | (y << 9) | x; + let mut out = [0i32; 8]; + // Down on A. + assert_eq!(ui.touch_hits(&[pack(3, 20, 20)], &mut out), 1); + assert_eq!(out[0], a); + // Drag over B: the down hit is CARRIED, never re-resolved (implicit capture). + assert_eq!(ui.touch_hits(&[pack(3, 120, 20)], &mut out), 1); + assert_eq!(out[0], a, "capture: the hit stays the down-frame hit"); + // Lift, then a NEW id lands on B: fresh resolve. + assert_eq!(ui.touch_hits(&[], &mut out), 0); + assert_eq!(ui.touch_hits(&[pack(4, 120, 20)], &mut out), 1); + assert_eq!(out[0], b); + // Two simultaneous contacts resolve independently, in wire order. + ui.touch_hits(&[], &mut out); + let n = ui.touch_hits(&[pack(1, 20, 20), pack(2, 120, 20)], &mut out); + assert_eq!((n, out[0], out[1]), (2, a, b)); +} + +#[test] +fn touch_decode_reads_both_packings() { + assert_eq!(crate::touch::decode((7 << 18) | (200 << 9) | 300), (7, 300.0, 200.0)); + assert_eq!( + crate::touch::decode(0x8000_0000 | (9 << 20) | (600 << 10) | 700), + (9, 700.0, 600.0) + ); +} + +#[test] +fn hit_pass_layers_never_swallow_bounds_hits() { + let mut ui = Ui::new(); + let content = abs_box(&mut ui, spec::ROOT_ID, 10.0, 10.0, 100.0, 50.0); + // A full-screen overlay layer ABOVE the content (the framework's portal + // root): hitPass makes its own box hit-transparent in BOTH walks, while + // its children still claim. + let overlay = ui.create_node(0); + ui.set_prop(overlay, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop(overlay, spec::prop::INSET_L, 0.0); + ui.set_prop(overlay, spec::prop::INSET_T, 0.0); + ui.set_prop(overlay, spec::prop::WIDTH, 480.0); + ui.set_prop(overlay, spec::prop::HEIGHT, 272.0); + ui.set_prop(overlay, spec::prop::HIT_PASS, 1.0); + ui.insert_before(spec::ROOT_ID, overlay, 0); + ui.tick(); + assert_eq!( + ui.hit_test_bounds(20.0, 20.0), + content, + "bounds facts resolve through the empty overlay to the content" + ); + // A toast INSIDE the overlay claims over the content beneath it. + let toast = abs_box(&mut ui, overlay, 15.0, 15.0, 30.0, 20.0); + assert_eq!(ui.hit_test_bounds(20.0, 20.0), toast); + assert_eq!(ui.hit_test(20.0, 20.0), toast, "ink walk honors overlay content too"); +} diff --git a/engine/core/src/touch.rs b/engine/core/src/touch.rs new file mode 100644 index 00000000..13f54988 --- /dev/null +++ b/engine/core/src/touch.rs @@ -0,0 +1,37 @@ +//! Touch hit facts (docs/TOUCH.md): the per-contact capture table behind +//! frame() argument 4. +//! +//! A host with input.touch resolves the bounds hit for each contact ONCE, on +//! the frame the contact appears, against the committed layout — the world +//! the user was looking at when the finger landed — and carries that node id +//! for the contact's lifetime (UIKit's implicit capture, host edition). The +//! guest never issues a hit query on the touch path; `Ui::touch_hits` is the +//! one entry hosts call right before invoking the guest frame. +//! +//! Wire packing (framework/src/touch.ts is the decoding twin): legacy form +//! bit31=0, x:9 y:9 id:8; wide form bit31=1, x:10 y:10 id:8 (append-only, +//! detected per contact word). + +const WIDE_MARKER: u32 = 0x8000_0000; + +/// (id, x, y) from one packed contact word — either packing form. +pub fn decode(packed: u32) -> (u8, f32, f32) { + let (coord_bits, mask) = if packed & WIDE_MARKER != 0 { + (10u32, 0x3ffu32) + } else { + (9u32, 0x1ffu32) + }; + let x = packed & mask; + let y = (packed >> coord_bits) & mask; + let id = (packed >> (coord_bits * 2)) & 0xff; + (id as u8, x as f32, y as f32) +} + +/// Contact-id -> hit-at-down slots. Eight is the wire cap for simultaneous +/// contacts; ids themselves may exceed 8 (Vita's sceTouch cycles 0..127). +#[derive(Default)] +pub struct HitTable { + pub(crate) ids: [u8; 8], + pub(crate) hits: [i32; 8], + pub(crate) live: [bool; 8], +} diff --git a/engine/wasm/src/lib.rs b/engine/wasm/src/lib.rs index adaa2e58..4292d595 100644 --- a/engine/wasm/src/lib.rs +++ b/engine/wasm/src/lib.rs @@ -199,6 +199,13 @@ pub extern "C" fn ui_hit_test(x: f32, y: f32) -> i32 { ui().hit_test(x, y) } +// ---- touch hit facts (spec op 42 hitTestBounds; docs/TOUCH.md) --------------- + +#[no_mangle] +pub extern "C" fn ui_hit_test_bounds(x: f32, y: f32) -> i32 { + ui().hit_test_bounds(x, y) +} + #[no_mangle] pub extern "C" fn ui_set_cursor(tex: i32, hot_x: f32, hot_y: f32, w: f32, h: f32) { ui().set_cursor(tex, hot_x, hot_y, w, h) diff --git a/framework/src/devtools.ts b/framework/src/devtools.ts index de2346bf..f7b3b4ee 100644 --- a/framework/src/devtools.ts +++ b/framework/src/devtools.ts @@ -174,9 +174,14 @@ 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[], hits?: readonly number[]) => void, +): (buttons: number, analog?: number, touches?: readonly number[], hits?: readonly number[]) => void { + return ( + buttons: number, + analogArg?: number, + touchArg?: readonly number[], + hitsArg?: readonly number[], + ) => { state.hostCalls++; if (state.transport) { pollTransport(); @@ -185,6 +190,7 @@ export function wrapFrameHandler( let mask = buttons; let analog = analogArg === undefined ? ANALOG_CENTER : analogArg & 0xffff; let touch = touchArg; + let hits = hitsArg; if (state.replayMasks) { if (state.replayAt < state.replayMasks.length) { mask = state.replayMasks[state.replayAt]; @@ -193,6 +199,12 @@ export function wrapFrameHandler( // into the deterministic tape. A v1 tape (no touch track) replays // every frame as no-contacts. touch = state.replayTouch ? state.replayTouch[state.replayAt] : undefined; + // Hit facts are DERIVED, not recorded: the host resolved them for the + // LIVE contacts, so they cannot describe the tape's. Dropping them + // sends the gesture layer down its deterministic query fallback + // (op 42/27 against the same committed layout — the same answer the + // recording host computed). + hits = undefined; state.replayAt++; } else { state.replayMasks = null; // tape exhausted: back to live input @@ -209,7 +221,7 @@ export function wrapFrameHandler( recordMask(mask, analog, touch); state.frame++; try { - h(mask, analog, touch); + h(mask, analog, touch, hits); } catch (e) { send({ t: "error", diff --git a/framework/src/gesture.ts b/framework/src/gesture.ts index a0cae711..1cd8bdfc 100644 --- a/framework/src/gesture.ts +++ b/framework/src/gesture.ts @@ -40,7 +40,7 @@ import { onCleanup } from "solid-js"; import { simulationHz, virtualFrame } from "./clock.ts"; -import { hitNode } from "./input.ts"; +import { resolveTouchHit } from "./input.ts"; import type { NodeMirror } from "./renderer.ts"; import { touches } from "./touch.ts"; @@ -67,6 +67,10 @@ export interface GestureContact { readonly vy: number; /** virtualFrame() at the down edge. */ readonly downFrame: number; + /** The down edge's hit FACT (TouchContact.hit): the node id the host + * bounds-resolved under the finger when it landed, carried for the + * contact's lifetime. undefined on hosts without the fact channel. */ + readonly hit?: number; /** Frames since down (0 on the down frame). */ readonly frames: number; } @@ -147,6 +151,7 @@ interface Track extends GestureContact { used: boolean; /** Seen in the current frame's snapshot (mark/sweep). */ present: boolean; + hit?: number; id: number; x: number; y: number; @@ -206,23 +211,25 @@ function withinSubtree(node: NodeMirror, ancestor: NodeMirror): boolean { return false; } -/** Region match for a down at (x, y). `hit` is the memoized ink hit for this - * down: undefined = not yet computed, null = computed and missed/no op. */ +/** Region match for a down at (x, y). `hit` memoizes the down's resolution: + * the host FACT when delivered (`fact` — TouchContact.hit), else one cold + * bounds/ink query. undefined = not yet resolved, null = resolved-miss. */ function regionMatches( rec: Recognizer, x: number, y: number, - hitBox: { hit: NodeMirror | null | undefined }, + hitBox: { hit: NodeMirror | null | undefined; fact: number | undefined }, ): boolean { const region = rec.opts.region; if (!region) return true; const target = region.node?.(); if (target) { - if (hitBox.hit === undefined) hitBox.hit = hitNode(x, y); + if (hitBox.hit === undefined) hitBox.hit = resolveTouchHit(x, y, hitBox.fact); const hit = hitBox.hit; if (hit) return withinSubtree(hit, target); - // Ink miss (or no hitTest op): the rect decides, when provided. A hit on - // ink OUTSIDE the subtree already returned above — occluders win. + // Miss (bare screen on a fact host, or no hit channel at all): the rect + // decides, when provided. A hit OUTSIDE the subtree already returned + // above — occluders win. } const r = region.rect?.(); if (!r) return false; @@ -251,9 +258,10 @@ function releaseTrack(t: Track): void { liveCount--; } -function beginTrack(t: Track, id: number, x: number, y: number): void { +function beginTrack(t: Track, id: number, x: number, y: number, fact: number | undefined): void { t.used = true; t.present = true; + t.hit = fact; t.id = id; t.x = x; t.y = y; @@ -275,9 +283,12 @@ function beginTrack(t: Track, id: number, x: number, y: number): void { t.claimedBy = null; liveCount++; - // Resolve owners in priority order (last-registered first); the ink hit is - // computed at most once per down, shared across recognizers. - const hitBox: { hit: NodeMirror | null | undefined } = { hit: undefined }; + // Resolve owners in priority order (last-registered first); the down's hit + // — the host fact, or at most one query — is shared across recognizers. + const hitBox: { hit: NodeMirror | null | undefined; fact: number | undefined } = { + hit: undefined, + fact, + }; for (let i = recognizers.length - 1; i >= 0; i--) { const rec = recognizers[i]; if (rec.disposed) continue; @@ -428,7 +439,7 @@ export function __runGestures(): void { break; } } - if (free) beginTrack(free, c.id, c.x, c.y); + if (free) beginTrack(free, c.id, c.x, c.y, c.hit); } // Up edges first (a released contact must not be re-recognized), then the diff --git a/framework/src/host.ts b/framework/src/host.ts index 099a0fd2..db252a15 100644 --- a/framework/src/host.ts +++ b/framework/src/host.ts @@ -88,6 +88,10 @@ export interface HostOps { /** Topmost node id at a logical point (paint-order hit testing; pure * layout containers pass through — see spec op 27). 0 = none. */ hitTest?(x: number, y: number): number; + /** Bounds-only twin of hitTest (spec op 42): pure layout containers CLAIM + * their box — the touch hit fact resolver's query form. The gesture layer + * only calls it when the host delivers no per-contact fact (frame() arg 4). */ + hitTestBounds?(x: number, y: number): number; /** Bind the cursor sprite: an uploaded texture drawn topmost every frame, * offset by its hotspot; never laid out, never hit-tested. tex < 0 hides * it; w/h <= 0 draw at the texture's own pixel size. */ diff --git a/framework/src/index.ts b/framework/src/index.ts index e72f6027..b7089ad4 100644 --- a/framework/src/index.ts +++ b/framework/src/index.ts @@ -241,6 +241,10 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi insetB: 0, insetL: 0, zIndex: 1000, + // Self-transparent to hit testing (spec prop hitPass): the empty layer + // must not swallow bounds hit facts aimed at app content beneath it — + // portal/OSK content INSIDE it still claims normally. + hitPass: 1, }); insertNode(rootMirror, appRoot); insertNode(rootMirror, overlayRoot); @@ -257,10 +261,10 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi initDevtools(host.ops); // DevTools shim (docs/DEVTOOLS.md): flight recorder + // debug channel; one branch per frame when no transport is connected. installFrameHandler( - wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[]) => { + wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[], hits?: 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 + __setTouches(touches, hits); // latch contacts + their host-resolved hit facts __drainEffects(); // frame-boundary deliveries enter the world first __runGestures(); // contact lifecycles resolve before app hooks read them runFrameHooks(buttons); // app lifecycle callbacks: onFrame/onButtonPress/etc. diff --git a/framework/src/input.ts b/framework/src/input.ts index f8c791bb..6595f7e8 100644 --- a/framework/src/input.ts +++ b/framework/src/input.ts @@ -700,6 +700,27 @@ export function hitNode(x: number, y: number): NodeMirror | null { return findMirror(hitRoot ?? root, ops.hitTest(x, y)); } +/** + * Touch-path hit authority (docs/TOUCH.md). The host-delivered FACT wins when + * present (`TouchContact.hit` — resolved once at the contact's down edge and + * carried); otherwise ONE cold query, preferring the bounds op (42) and + * tolerating ink-only hosts (op 27). Null = nothing claimed / no channel at + * all — region rects are the caller's last resort. + */ +export function resolveTouchHit( + x: number, + y: number, + fact: number | undefined, +): NodeMirror | null { + if (fact !== undefined) { + return fact === 0 ? null : findMirror(hitRoot ?? root, fact); + } + const ops = getOps(); + const query = ops.hitTestBounds ?? ops.hitTest; + if (!query) return null; + return findMirror(hitRoot ?? root, query(x, y)); +} + /** 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. */ diff --git a/framework/src/touch.ts b/framework/src/touch.ts index 0c253efa..1e5ddc07 100644 --- a/framework/src/touch.ts +++ b/framework/src/touch.ts @@ -9,6 +9,15 @@ export interface TouchContact { readonly x: number; /** Logical viewport Y coordinate. */ readonly y: number; + /** + * Touch hit FACT: the node id the host bounds-hit at this contact's DOWN + * edge (against the committed frame the user was looking at), carried for + * the contact's lifetime. `undefined` when the host predates the fact + * channel or during devtools replay — the gesture layer then falls back to + * a query (spec op 42 hitTestBounds, else op 27, else region rects). + * 0 means the host resolved and nothing claimed (off-screen edge cases). + */ + readonly hit?: number; } const LEGACY_COORD_BITS = 9; @@ -29,13 +38,16 @@ let snapshot: readonly TouchContact[] = EMPTY; * wider than 512 use the append-only wide form: bit31=1, x:10, y:10, id:8. * Per-contact detection keeps every PSP/Vita tape and host byte-compatible. */ -export function __setTouches(packed: readonly number[] | undefined): void { +export function __setTouches( + packed: readonly number[] | undefined, + hits?: readonly number[], +): void { if (!packed || packed.length === 0) { snapshot = EMPTY; return; } snapshot = Object.freeze( - packed.slice(0, 8).map((value) => { + packed.slice(0, 8).map((value, index) => { const wide = (value & WIDE_MARKER) !== 0; const coordBits = wide ? WIDE_COORD_BITS : LEGACY_COORD_BITS; const coordMask = wide ? WIDE_COORD_MASK : LEGACY_COORD_MASK; @@ -44,6 +56,7 @@ export function __setTouches(packed: readonly number[] | undefined): void { id: (value >>> idShift) & 0xff, x: value & coordMask, y: (value >>> coordBits) & coordMask, + hit: hits?.[index], }); }), ); @@ -67,6 +80,41 @@ export function __packTouch(id: number, x: number, y: number): number { ) >>> 0; } +/** + * TS-host helper: the host-side per-contact capture table behind frame() + * argument 4 (Rust twin: pocketjs_core::Ui::touch_hits). Each NEW contact id + * is resolved ONCE through `query` (the bounds hit, spec op 42) and the node + * id is carried until the id lifts. Hosts call this right before invoking the + * guest frame; `undefined` (no contacts) keeps arg 4 absent. + */ +export function createTouchHitFacts( + query: (x: number, y: number) => number, +): (packed: readonly number[] | undefined) => number[] | undefined { + const table = new Map(); + return (packed) => { + if (!packed || packed.length === 0) { + table.clear(); + return undefined; + } + const seen = new Set(); + const hits = packed.slice(0, 8).map((value) => { + const wide = (value & WIDE_MARKER) !== 0; + const coordBits = wide ? WIDE_COORD_BITS : LEGACY_COORD_BITS; + const coordMask = wide ? WIDE_COORD_MASK : LEGACY_COORD_MASK; + const id = (value >>> (coordBits * 2)) & 0xff; + seen.add(id); + let hit = table.get(id); + if (hit === undefined) { + hit = query(value & coordMask, (value >>> coordBits) & coordMask); + table.set(id, hit); + } + return hit; + }); + for (const id of [...table.keys()]) if (!seen.has(id)) table.delete(id); + return hits; + }; +} + /** Test/native helper for logical viewports up to 1024 pixels per axis. */ export function __packTouchWide(id: number, x: number, y: number): number { return ( diff --git a/framework/src/virtual-list.ts b/framework/src/virtual-list.ts index 798c320e..97fb9253 100644 --- a/framework/src/virtual-list.ts +++ b/framework/src/virtual-list.ts @@ -34,7 +34,7 @@ import { createGesture, type GestureContact } from "./gesture.ts"; import { focusNode, getFocused, - hitFocusable, + resolveTouchHit, pressNode, pushFocusController, setActiveNode, @@ -85,9 +85,6 @@ export interface VirtualListProps { stickToBottom?: boolean; /** Gate d-pad/touch input (e.g. `() => !osk.isOpen()`). Default on. */ inputActive?: () => boolean; - /** Viewport geometry in screen px — the touch fallback when the host has - * no hitTest op, and the complement for contacts on unpainted gaps. */ - touchRect?: () => { x: number; y: number; w: number; h: number } | null; /** Extra style merged onto the viewport (height/overflow stay owned here). */ style?: Record; ref?: (handle: VirtualListHandle) => void; @@ -236,24 +233,22 @@ export function VirtualList(props: VirtualListProps): SolidJSX.Element { // ---- touch -------------------------------------------------------------- const rowFromContact = (c: GestureContact): { index: number; node: NodeMirror | null } | null => { - // Ink path: the nearest focusable under the finger, matched to a row. - const hit = hitFocusable(c.x, c.y); + // The contact's hit fact (or its cold-query fallback) names the exact + // node under the finger; the row is whichever mounted row's subtree + // contains it. Bounds semantics make this total for full-bleed rows; a + // hit on the canvas/viewport itself is a row GAP — a separator tap, and + // separators don't press (the UIKit convention). + const hit = resolveTouchHit(c.x, c.y, c.hit); if (hit) { for (const [i, n] of rowNodes) { if (isWithin(hit, n)) return { index: i, node: n }; } } - // Geometry fallback (no hitTest, or the finger sits on an unpainted gap). - const rect = props.touchRect?.(); - if (rect && c.x >= rect.x && c.x < rect.x + rect.w && c.y >= rect.y && c.y < rect.y + rect.h) { - const index = Math.floor((untrack(offset) + (c.y - rect.y)) / props.rowHeight); - if (index >= 0 && index < props.count) return { index, node: rowNodes.get(index) ?? null }; - } return null; }; createGesture({ - region: { node: () => viewportNode, rect: () => props.touchRect?.() ?? null }, + region: { node: () => viewportNode }, axis: "y", onDown: (c) => { if (!active()) return; diff --git a/hosts/sim/sim.ts b/hosts/sim/sim.ts index f0551d7f..39b2f9a6 100644 --- a/hosts/sim/sim.ts +++ b/hosts/sim/sim.ts @@ -28,7 +28,7 @@ import { fileURLToPath } from "node:url"; import { join, resolve } from "node:path"; import { createWasmUi } from "../web/wasm-ops.js"; import { normalizeHz, TICKS_PER_SECOND } from "../../framework/src/clock.ts"; -import { __packTouch } from "../../framework/src/touch.ts"; +import { createTouchHitFacts, __packTouch } from "../../framework/src/touch.ts"; const ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url))); // PocketJS/ const DIST = join(ROOT, "dist/"); @@ -251,12 +251,20 @@ export async function bootWorld( if (extraGlobals) Object.assign(g, extraGlobals); const src = await Bun.file(DIST + app + ".js").text(); (0, eval)(src); - const frame = g.frame as - | ((buttons: number, analog?: number, touches?: readonly number[]) => void) + const appFrame = g.frame as + | ((buttons: number, analog?: number, touches?: readonly number[], hits?: readonly number[]) => void) | undefined; - if (typeof frame !== "function") { + if (typeof appFrame !== "function") { throw new Error("sim: bundle did not install globalThis.frame (entry must call render()/mount())"); } + // Touch hit facts (docs/TOUCH.md): the sim is a host, so it resolves each + // new contact's bounds hit against the committed core frame and carries it + // — the guest never queries on the touch path, exactly like device hosts. + 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)); return { frame, tick: wasm.tick, diff --git a/hosts/vita/src/ffi.rs b/hosts/vita/src/ffi.rs index f6737a10..50d19ada 100644 --- a/hosts/vita/src/ffi.rs +++ b/hosts/vita/src/ffi.rs @@ -461,6 +461,21 @@ unsafe extern "C" fn js_hit_test( JS_NewInt32(ctx, id) } +/// Bounds-only hit twin (spec op 42) — the touch hit fact's cold-query form +/// (devtools replay resolves through this; the live path rides frame arg 4). +unsafe extern "C" fn js_hit_test_bounds( + ctx: *mut JSContext, + _this: JSValue, + argc: i32, + argv: *mut JSValue, +) -> JSValue { + let id = ui().hit_test_bounds( + arg_f64(ctx, argc, argv, 0) as f32, + arg_f64(ctx, argc, argv, 1) as f32, + ); + JS_NewInt32(ctx, id) +} + unsafe extern "C" fn js_set_cursor( ctx: *mut JSContext, _this: JSValue, @@ -919,6 +934,7 @@ pub unsafe fn register( add_fn(ctx, ui_obj, b"setActive\0", js_set_active, 2); // Virtual cursor ops (spec ops 27..29, input.cursor). add_fn(ctx, ui_obj, b"hitTest\0", js_hit_test, 2); + add_fn(ctx, ui_obj, b"hitTestBounds\0", js_hit_test_bounds, 2); add_fn(ctx, ui_obj, b"setCursor\0", js_set_cursor, 5); add_fn(ctx, ui_obj, b"setCursorPos\0", js_set_cursor_pos, 2); add_fn(ctx, ui_obj, b"loadStyles\0", js_load_styles, 1); diff --git a/hosts/vita/src/lib.rs b/hosts/vita/src/lib.rs index 215d3a04..7becc077 100644 --- a/hosts/vita/src/lib.rs +++ b/hosts/vita/src/lib.rs @@ -247,20 +247,38 @@ impl Runtime { analog: i32, touches: &input::TouchSnapshot, ) -> Result<(), String> { + let packed = touches.packed(); let touch_array = JS_NewArray(self.ctx); - for (index, packed) in touches.packed().iter().enumerate() { + for (index, value) in packed.iter().enumerate() { JS_SetPropertyUint32( self.ctx, touch_array, index as u32, - JS_NewInt32(self.ctx, *packed as i32), + JS_NewInt32(self.ctx, *value as i32), + ); + } + // Touch hit facts (frame arg 4, docs/TOUCH.md): each NEW contact is + // bounds-hit ONCE against the committed frame the user is looking at + // and carried by the core's capture table — the guest never queries + // on the touch path. + let mut hits = [0i32; 8]; + let hit_count = ffi::ui().touch_hits(packed, &mut hits); + let hits_array = JS_NewArray(self.ctx); + for (index, hit) in hits[..hit_count].iter().enumerate() { + JS_SetPropertyUint32( + self.ctx, + hits_array, + index as u32, + JS_NewInt32(self.ctx, *hit), ); } let result = self.call_frame(&mut [ JS_NewInt32(self.ctx, buttons), JS_NewInt32(self.ctx, analog), touch_array, + hits_array, ]); + JS_FreeValue(self.ctx, hits_array); JS_FreeValue(self.ctx, touch_array); result } diff --git a/hosts/web/wasm-ops.js b/hosts/web/wasm-ops.js index da3bf20a..40d3a89a 100644 --- a/hosts/web/wasm-ops.js +++ b/hosts/web/wasm-ops.js @@ -117,6 +117,9 @@ export async function createWasmUi(wasm, options = {}) { // a stale pocketjs.wasm predating them still boots (enableCursor falls // back to the classic d-pad focus model when the host lacks them). if (ex.ui_hit_test) ops.hitTest = (x, y) => ex.ui_hit_test(x, y); + // Touch hit facts (spec op 42): the bounds-only query twin. Same stale-wasm + // tolerance — the gesture layer falls back to ink hitTest, then rects. + if (ex.ui_hit_test_bounds) ops.hitTestBounds = (x, y) => ex.ui_hit_test_bounds(x, y); if (ex.ui_set_cursor) { ops.setCursor = (tex, hotX, hotY, w, h) => ex.ui_set_cursor(tex, hotX, hotY, w, h); } diff --git a/tests/golden.ts b/tests/golden.ts index d4ef32de..59dd0681 100644 --- a/tests/golden.ts +++ b/tests/golden.ts @@ -21,6 +21,7 @@ import { join, resolve } from "node:path"; import { createWasmUi } from "../hosts/web/wasm-ops.js"; import { SCREEN_H, SCREEN_W } from "../contracts/spec/spec.ts"; import { GOLDEN_SPECS, packedTouchFor, type GoldenSpec } from "./golden-specs.ts"; +import { createTouchHitFacts } from "../framework/src/touch.ts"; const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); // PocketJS/ // Goldens never consume the shared dist/ directory: it may contain ignored, @@ -113,16 +114,22 @@ async function runDemo(spec: GoldenSpec): Promise> { const src = await Bun.file(DIST + bundle + ".js").text(); (0, eval)(src); // IIFE mounts the app and installs globalThis.frame const frame = g.frame as - | ((buttons: number, analog?: number, touches?: readonly number[]) => void) + | ((buttons: number, analog?: number, touches?: readonly number[], hits?: readonly number[]) => void) | undefined; if (typeof frame !== "function") { throw new Error("bundle did not install globalThis.frame (does the entry call render()?)"); } + // Touch hit facts: the oracle is a host too — resolve at the down edge, + // carry per contact, deliver as frame() arg 4 (docs/TOUCH.md). + const boundsQuery = (wasm.ops as { hitTestBounds?: (x: number, y: number) => number }) + .hitTestBounds; + const hitFacts = boundsQuery ? createTouchHitFacts(boundsQuery) : undefined; const captures = new Map(); const want = new Set(spec.capture); for (let f = 0; f < spec.frames; f++) { - // input + effects + sweep (touch rides the third frame argument) - frame(spec.input ? spec.input(f) : 0, undefined, packedTouchFor(spec, f)); + // input + effects + sweep (touch rides args 3+4) + const packed = packedTouchFor(spec, f); + frame(spec.input ? spec.input(f) : 0, undefined, packed, hitFacts?.(packed)); wasm.tick(); // anims + layout, exactly 1/60 s if (want.has(f)) captures.set(f, wasm.render().slice()); } diff --git a/tests/virtual-list.test.ts b/tests/virtual-list.test.ts index 17567819..7427b8ce 100644 --- a/tests/virtual-list.test.ts +++ b/tests/virtual-list.test.ts @@ -54,8 +54,13 @@ let dispose: (() => void) | null = null; const g = globalThis as Record; -function frame(buttons = 0, touches?: readonly number[]): void { - (g.frame as (b: number, a?: number, t?: readonly number[]) => void)(buttons, undefined, touches); +function frame(buttons = 0, touches?: readonly number[], hits?: readonly number[]): void { + (g.frame as (b: number, a?: number, t?: readonly number[], h?: readonly number[]) => void)( + buttons, + undefined, + touches, + hits, + ); } beforeEach(() => { @@ -78,17 +83,24 @@ function canvasNode(): NodeMirror { return rootMirror.children[0].children[0].children[0]; } +function viewportNode(): NodeMirror { + return rootMirror.children[0].children[0]; +} + +/** The hit FACT for a contact inside the viewport (frame() arg 4): tests are + * hosts too, and this host resolves hits by construction. */ +function vpHit(): number[] { + return [viewportNode().id]; +} + interface MountOpts { count?: () => number; onRowPress?: (i: number) => void; focusRows?: boolean; stickToBottom?: boolean; onNearEnd?: () => void; - touchRect?: () => { x: number; y: number; w: number; h: number }; } -const LIST_RECT = { x: 0, y: 0, w: 480, h: 50 }; - function mountList(opts: MountOpts = {}): VirtualListHandle { let handle: VirtualListHandle | undefined; dispose = publicRender( @@ -104,7 +116,6 @@ function mountList(opts: MountOpts = {}): VirtualListHandle { onRowPress: opts.onRowPress, stickToBottom: opts.stickToBottom, onNearEnd: opts.onNearEnd, - touchRect: opts.touchRect ?? (() => LIST_RECT), renderRow: (i) => Text({ children: `ROW ${i}` }), ref: (h) => { handle = h; @@ -188,11 +199,13 @@ describe("d-pad focus", () => { }); describe("touch", () => { - test("tap on a row fires the shared onPress path (geometry fallback, no hitTest)", () => { + test("tap on a row fires the shared onPress path (hit fact names the row)", () => { const pressed: number[] = []; const h = mountList({ onRowPress: (i) => pressed.push(i) }); h.scroller.scrollTo(100, { immediate: true }); - frame(0, [__packTouch(1, 100, 25)]); // y 25 in-view → content y 125 → row 12 + // y 25 in-view → content y 125 → row 12; window first = 8 → child 4. + const row12 = canvasNode().children[4]; + frame(0, [__packTouch(1, 100, 25)], [row12.id]); frame(0); // release expect(pressed).toEqual([12]); expect(h.focusedIndex()).toBe(12); @@ -201,11 +214,12 @@ describe("touch", () => { test("pan claims the contact, follows the finger, and flings on release", () => { const pressed: number[] = []; const h = mountList({ onRowPress: (i) => pressed.push(i) }); - // Drag upward 12 px/frame (content scrolls down), then release. - frame(0, [__packTouch(1, 100, 45)]); - frame(0, [__packTouch(1, 100, 33)]); - frame(0, [__packTouch(1, 100, 21)]); - frame(0, [__packTouch(1, 100, 9)]); + // Drag upward 12 px/frame (content scrolls down), then release. The host + // fact resolves to the viewport box — a bounds hit ANYWHERE in the list. + frame(0, [__packTouch(1, 100, 45)], vpHit()); + frame(0, [__packTouch(1, 100, 33)], vpHit()); + frame(0, [__packTouch(1, 100, 21)], vpHit()); + frame(0, [__packTouch(1, 100, 9)], vpHit()); const atRelease = h.scroller.offset(); expect(atRelease).toBeGreaterThan(20); // finger-follow moved the content frame(0); // release → fling @@ -221,15 +235,28 @@ describe("touch", () => { }); test("a down arrests an in-flight fling", () => { + const h = mountList(); + frame(0, [__packTouch(1, 100, 45)], vpHit()); + frame(0, [__packTouch(1, 100, 25)], vpHit()); + frame(0, [__packTouch(1, 100, 5)], vpHit()); + frame(0); // release → fling + frame(0); + expect(h.scroller.state()).toBe("fling"); + frame(0, [__packTouch(2, 100, 25)], vpHit()); // catch + expect(h.scroller.state()).not.toBe("fling"); + }); + + test("no fact channel: the gesture layer queries ops.hitTestBounds instead", () => { + // The injected host GAINS the bounds op (a stale-host shim would lack + // both — and then the region simply never matches, PSP-style inertness). + host.ops.hitTestBounds = () => viewportNode().id; const h = mountList(); frame(0, [__packTouch(1, 100, 45)]); frame(0, [__packTouch(1, 100, 25)]); frame(0, [__packTouch(1, 100, 5)]); - frame(0); // release → fling + frame(0); frame(0); expect(h.scroller.state()).toBe("fling"); - frame(0, [__packTouch(2, 100, 25)]); // catch - expect(h.scroller.state()).not.toBe("fling"); }); });