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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions contracts/spec/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -446,6 +468,7 @@ export const PROP_VALUE_KIND: Record<PropName, number> = {
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,
Expand Down
25 changes: 20 additions & 5 deletions engine/core/src/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
Expand All @@ -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);
}
}
Expand All @@ -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);
});
}

Expand Down
58 changes: 58 additions & 0 deletions engine/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion engine/core/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions engine/core/src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
87 changes: 87 additions & 0 deletions engine/core/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
37 changes: 37 additions & 0 deletions engine/core/src/touch.rs
Original file line number Diff line number Diff line change
@@ -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],
}
7 changes: 7 additions & 0 deletions engine/wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading