From 120e8b3142932225ccd84b781db8cb4fa345bcff Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 6 Oct 2026 22:02:00 -0500 Subject: [PATCH 1/4] Move widget routing and focus ownership to TypeScript - Compile complete hit testing, capture and event routing with explicit portable ownership. - Preserve native reference focus, geometry, identities and copied ABI results. --- build.zig | 5 + packages/core/src/runtime_policy.ts | 1 + packages/core/src/view_frontend.ts | 1 + packages/core/src/widget_routing.ts | 246 ++++++++++++++++++ packages/core/test/view_frontend.test.ts | 2 +- packages/core/test/widget_routing.test.ts | 95 +++++++ src/primitives/canvas/root.zig | 1 + src/primitives/canvas/tokens.zig | 2 + src/primitives/canvas/widget_routing.zig | 69 +++++ .../canvas/widget_routing_policy.zig | 121 +++++++++ src/primitives/canvas/widget_runtime.zig | 5 +- src/runtime/ts_ui_app.zig | 2 + src/runtime/ui_app.zig | 3 + src/runtime/view_widget_tree.zig | 1 + tests/ts-core/persist_e2e_tests.zig | 1 + tests/ts-core/typography_e2e_tests.zig | 2 + tests/ts-core/widget_routing_e2e_tests.zig | 237 +++++++++++++++++ 17 files changed, 792 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/widget_routing.ts create mode 100644 packages/core/test/widget_routing.test.ts create mode 100644 src/primitives/canvas/widget_routing_policy.zig create mode 100644 tests/ts-core/widget_routing_e2e_tests.zig diff --git a/build.zig b/build.zig index 2f36140ed..8f81ff252 100644 --- a/build.zig +++ b/build.zig @@ -1321,6 +1321,7 @@ pub fn build(b: *std.Build) void { native_api_tests.addFileArg(b.path("packages/core/test/component_construction.test.ts")); native_api_tests.addFileArg(b.path("packages/core/test/widget_motion.test.ts")); native_api_tests.addFileArg(b.path("packages/core/test/widget_audits.test.ts")); + native_api_tests.addFileArg(b.path("packages/core/test/widget_routing.test.ts")); native_api_tests.addFileArg(b.path("packages/core/test/component_composition.test.ts")); native_api_tests.addFileArg(b.path("packages/core/test/view_frontend.test.ts")); native_api_tests.has_side_effects = true; @@ -1334,6 +1335,7 @@ pub fn build(b: *std.Build) void { const host_e2e_run = b.addRunArtifact(ts_core_artifacts.host); const persist_e2e_run = b.addRunArtifact(ts_core_artifacts.persist); b.step("test-ts-control-appearance", "Compare complete compiled control appearance with native behavior").dependOn(&persist_e2e_run.step); + b.step("test-ts-widget-routing", "Compare complete compiled input routing and focus with the native reference").dependOn(&b.addRunArtifact(ts_core_artifacts.widget_routing).step); b.step("test-ts-widget-audits", "Compare compiled accessibility and layout audits with native rules").dependOn(&b.addRunArtifact(ts_core_artifacts.widget_audits).step); b.step("test-ts-markdown-content", "Compare complete compiled Markdown with native behavior").dependOn(&b.addRunArtifact(ts_core_artifacts.markdown_content).step); b.step("test-ts-component-construction", "Compare complete compiled construction with native behavior").dependOn(&b.addRunArtifact(ts_core_artifacts.construction).step); @@ -4179,6 +4181,7 @@ const TsCoreE2eArtifacts = struct { construction: *std.Build.Step.Compile, markdown_content: *std.Build.Step.Compile, widget_audits: *std.Build.Step.Compile, + widget_routing: *std.Build.Step.Compile, motion: *std.Build.Step.Compile, composition: *std.Build.Step.Compile, composition_view: *std.Build.Step.Compile, @@ -4995,6 +4998,7 @@ fn tsCoreE2eArtifact( .host = filteredTestArtifact(b, e2e_mod, "ts-core-e2e-tests", &.{}), .persist = filteredTestArtifact(b, persist_mod, "ts-persist-e2e-tests", &.{}), .markdown_content = filteredTestArtifact(b, persist_mod, "ts-markdown-content-tests", &.{"compiled Markdown"}), + .widget_routing = filteredTestArtifact(b, persist_mod, "ts-widget-routing-tests", &.{"compiled widget routing"}), .widget_audits = filteredTestArtifact(b, persist_mod, "ts-widget-audits-tests", &.{"compiled widget audits"}), .construction = filteredTestArtifact(b, persist_mod, "ts-construction-e2e-tests", &.{"compiled construction"}), .composition_view = filteredTestArtifact(b, composition_decoder_mod, "ts-composition-view-tests", &.{"compiled grouped input"}), @@ -5378,6 +5382,7 @@ fn externalCoreFixtureModule( stage_run.addFileInput(b.path("packages/core/src/code_content.ts")); stage_run.addFileInput(b.path("packages/core/src/markdown_content.ts")); stage_run.addFileInput(b.path("packages/core/src/widget_audits.ts")); + stage_run.addFileInput(b.path("packages/core/src/widget_routing.ts")); stage_run.addFileInput(b.path("packages/core/src/stream_policy.ts")); stage_run.addArg("--src"); stage_run.addDirectoryArg(spec.src_dir); diff --git a/packages/core/src/runtime_policy.ts b/packages/core/src/runtime_policy.ts index c8b1d71ce..2230e7f10 100644 --- a/packages/core/src/runtime_policy.ts +++ b/packages/core/src/runtime_policy.ts @@ -216,6 +216,7 @@ export function native_window_policy(request: Uint8Array): Uint8Array { if (request[0] === 21) return nscvChartContentPolicy(request); if (request[0] === 22) return nscvMarkdownContentPolicy(request); if (request[0] === 23) return nscvWidgetAudits(request); + if (request[0] === 24) return nscvWidgetRouting(request); const data = new DataView(request.buffer, request.byteOffset, request.byteLength); let at = 0; const byte = (): number => { diff --git a/packages/core/src/view_frontend.ts b/packages/core/src/view_frontend.ts index 5f20b04e5..37beda263 100644 --- a/packages/core/src/view_frontend.ts +++ b/packages/core/src/view_frontend.ts @@ -917,4 +917,5 @@ const viewPrelude = "\n// Portable Native components compiled beside the committ readFileSync(new URL("./chart_content.ts", import.meta.url), "utf8") + readFileSync(new URL("./markdown_content.ts", import.meta.url), "utf8") + readFileSync(new URL("./widget_audits.ts", import.meta.url), "utf8") + + readFileSync(new URL("./widget_routing.ts", import.meta.url), "utf8") + readFileSync(new URL("./stream_policy.ts", import.meta.url), "utf8"); diff --git a/packages/core/src/widget_routing.ts b/packages/core/src/widget_routing.ts new file mode 100644 index 000000000..858fe532b --- /dev/null +++ b/packages/core/src/widget_routing.ts @@ -0,0 +1,246 @@ +/** Routing over copied, solved widget facts. Identities remain two raw words; + * this owner chooses hit targets, containment, capture/bubble paths and focus. + * Scroll range is supplied by the separate portable semantic observation owner. + * Every geometric operation retains the reference's f32 evaluation order. */ +interface NscRouteNode { + kind: number; role: number; parent: number; depth: number; lo: number; hi: number; + flags: number; actions: number; value: number; layer: number; + frame: NscSurfaceRect; transform: number[]; +} +interface NscRouteEntry { index: number; phase: number } +function nscvRouteModal(n: NscRouteNode): boolean { return n.kind >= 19 && n.kind <= 21; } +function nscvRouteEscapes(n: NscRouteNode): boolean { return (n.flags & 8) !== 0 || nscvRouteModal(n); } +function nscvRouteClips(n: NscRouteNode): boolean { return n.kind === 6 || (n.flags & 16) !== 0; } +function nscvRouteLive(n: NscRouteNode): boolean { return (n.lo !== 0 || n.hi !== 0) && (n.flags & 2) === 0; } +function nscvRouteClaims(n: NscRouteNode): boolean { + const k = n.kind; + return nscvRouteLive(n) && ((n.actions & 262) !== 0 || k >= 31 && k <= 39 || k === 41 || k === 42 || k === 44 || k >= 46 && k <= 51 || k === 6 || k === 14 || k === 16 || k >= 19 && k <= 21 || k >= 23 && k <= 25 || k === 58 || k === 62); +} +function nscvRouteHit(n: NscRouteNode, hover: boolean): boolean { + if (!nscvRouteLive(n)) return false; + if ((n.actions & 262) !== 0 || (n.flags & 64) !== 0 || hover && (n.flags & 32) !== 0) return true; + const k = n.kind; + if (k === 56) return (n.flags & 1024) !== 0; + return k === 6 || k >= 14 && k <= 26 || k >= 31 && k <= 39 || k === 41 || k === 42 || k >= 44 && k <= 52 || k === 58 || k === 62; +} +function nscvRouteDefaultFocus(n: NscRouteNode): boolean { + const k = n.kind; + return n.role === 25 || k === 6 || k === 14 || k >= 31 && k <= 39 || k === 41 || k === 42 || k === 44 || k >= 46 && k <= 51 || k === 58 || k === 62; +} +function nscvRouteContains(r: NscSurfaceRect, x: number, y: number): boolean { + return !(r.width <= 0 || r.height <= 0) && x >= r.x && y >= r.y && x < Math.fround(r.x + r.width) && y < Math.fround(r.y + r.height); +} +function nscvRouteLocal(n: NscRouteNode, x: number, y: number): { x: number; y: number; valid: boolean } { + const t = n.transform, a = t[0]!, b = t[1]!, c = t[2]!, d = t[3]!, tx = t[4]!, ty = t[5]!, f = Math.fround; + if (a === 1 && b === 0 && c === 0 && d === 1 && tx === 0 && ty === 0) return { x, y, valid: true }; + const det = f(f(a * d) - f(b * c)); + if (Math.abs(det) <= f(0.000001)) return { x: 0, y: 0, valid: false }; + const inv = f(1 / det); + return { x: f(f(f(f(d * inv) * x) + f(f(-c * inv) * y)) + f(f(f(c * ty) - f(d * tx)) * inv)), + y: f(f(f(f(-b * inv) * x) + f(f(a * inv) * y)) + f(f(f(b * tx) - f(a * ty)) * inv)), valid: true }; +} +function nscvRouteSettled(nodes: NscRouteNode[], index: number): boolean { + const n = nodes[index]!, f = Math.fround; + if ((n.flags & 4) === 0 && !(n.value >= 0.5)) return false; + let bottom = -Infinity, i = index + 1; + while (i < nodes.length && nodes[i]!.depth > n.depth) { + const child = nodes[i]!; + if (!nscvRouteEscapes(child)) bottom = nscvSurfaceMax(bottom, f(child.frame.y + child.frame.height)); + if (nscvRouteEscapes(child) || child.kind === 14) { + i++; while (i < nodes.length && nodes[i]!.depth > child.depth) i++; + } else i++; + } + return bottom === -Infinity || bottom <= f(f(n.frame.y + n.frame.height) + 0.5); +} +function nscvRouteHidden(nodes: NscRouteNode[], index: number): boolean { + let i = index; + while (i < nodes.length) { const n = nodes[i]!; if ((n.flags & 1) !== 0) return true; i = n.parent; } + return false; +} +function nscvRouteConcealed(nodes: NscRouteNode[], index: number): boolean { + let i = nodes[index]!.parent; + while (i < nodes.length) { const n = nodes[i]!; if (n.kind === 14 && !nscvRouteSettled(nodes, i)) return true; i = n.parent; } + return false; +} +function nscvRouteVisible(nodes: NscRouteNode[], index: number, point: boolean, x: number, y: number): boolean { + const r = nodes[index]!.frame, f = Math.fround; + if (!point && (r.width <= 0 || r.height <= 0)) return false; + let i = index; + while (i < nodes.length) { + if (nscvRouteEscapes(nodes[i]!)) return true; + i = nodes[i]!.parent; if (i >= nodes.length) return true; + const p = nodes[i]!; + if (nscvRouteClips(p)) { + if (point) { if (!nscvRouteContains(p.frame, x, y)) return false; } + else { + const a = nscvSurfaceMax(r.x, p.frame.x), b = nscvSurfaceMax(r.y, p.frame.y); + const c = nscvSurfaceMin(f(r.x + r.width), f(p.frame.x + p.frame.width)), d = nscvSurfaceMin(f(r.y + r.height), f(p.frame.y + p.frame.height)); + if (c <= a || d <= b || f(c - a) <= 0 || f(d - b) <= 0) return false; + } + } + } + return true; +} +function nscvRouteLayer(n: NscRouteNode, layers: number[], surface: boolean): number { + if ((n.flags & 512) !== 0) return n.layer; + return nscvRouteModal(n) ? layers[2]! : surface && (n.flags & 8) !== 0 || n.kind >= 23 && n.kind <= 25 ? layers[1]! : n.kind === 40 ? layers[3]! : layers[0]!; +} +function nscvRouteSurfaceLayer(nodes: NscRouteNode[], index: number, layers: number[]): number { + const n = nodes[index]!; + let layer = nscvRouteLayer(n, layers, true), p = n.parent; + if ((n.flags & 512) !== 0) return layer; + while (p < nodes.length) { const ancestor = nodes[p]!; if (nscvRouteEscapes(ancestor)) layer = Math.max(layer, nscvRouteLayer(ancestor, layers, true)); p = ancestor.parent; } + return layer; +} +function nscvRouteHitChildren(nodes: NscRouteNode[], parent: number, x: number, y: number, layers: number[], hover: boolean): number { + const missing = 4294967295; + let previous = missing, previousLayer = 0; + for (let tested = 0; tested < nodes.length; tested++) { + let best = missing, layer = 0; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]!; if (n.parent !== parent) continue; + const l = nscvRouteLayer(n, layers, false); + if (previous !== missing && (l > previousLayer || l === previousLayer && i >= previous)) continue; + if (best === missing || l > layer || l === layer && i > best) { best = i; layer = l; } + } + if (best === missing) return missing; + previous = best; previousLayer = layer; + if (!nscvRouteEscapes(nodes[best]!)) { const hit = nscvRouteHitNode(nodes, best, x, y, layers, hover); if (hit !== missing) return hit; } + } + return missing; +} +function nscvRouteHitNode(nodes: NscRouteNode[], index: number, x: number, y: number, layers: number[], hover: boolean): number { + const n = nodes[index]!, missing = 4294967295; + if ((n.flags & 1) !== 0) return missing; + const p = nscvRouteLocal(n, x, y); + if (!p.valid || nscvRouteClips(n) && !nscvRouteContains(n.frame, p.x, p.y)) return missing; + if (n.kind !== 14 || nscvRouteSettled(nodes, index)) { const hit = nscvRouteHitChildren(nodes, index, p.x, p.y, layers, hover); if (hit !== missing) return hit; } + return nscvRouteHit(n, hover) && nscvRouteContains(n.frame, p.x, p.y) ? index : missing; +} +function nscvRouteHitTree(nodes: NscRouteNode[], x: number, y: number, layers: number[], hover: boolean): number { + const missing = 4294967295; + let previous = missing, previousLayer = 0; + for (let tested = 0; tested < nodes.length; tested++) { + let best = missing, layer = 0; + for (let i = 0; i < nodes.length; i++) { + if (!nscvRouteEscapes(nodes[i]!)) continue; + const l = nscvRouteSurfaceLayer(nodes, i, layers); + if (previous !== missing && (l > previousLayer || l === previousLayer && i >= previous)) continue; + if (best === missing || l > layer || l === layer && i > best) { best = i; layer = l; } + } + if (best === missing) break; + previous = best; previousLayer = layer; + if (nscvRouteHidden(nodes, best) || nscvRouteConcealed(nodes, best)) continue; + const hit = nscvRouteHitNode(nodes, best, x, y, layers, hover); if (hit !== missing) return hit; + } + return nscvRouteHitChildren(nodes, missing, x, y, layers, hover); +} +function nscvRoutePress(nodes: NscRouteNode[], index: number): number { + let i = index; while (i < nodes.length) { if (nscvRouteClaims(nodes[i]!)) return i; i = nodes[i]!.parent; } return 4294967295; +} +function nscvRouteFocus(nodes: NscRouteNode[], index: number, logical: boolean): boolean { + const n = nodes[index]!; + return nscvRouteLive(n) && !nscvRouteHidden(nodes, index) && !nscvRouteConcealed(nodes, index) && + ((n.flags & 128) !== 0 || (n.actions & 1) !== 0 || nscvRouteDefaultFocus(n) || (n.flags & 256) !== 0) && (logical || nscvRouteVisible(nodes, index, false, 0, 0)); +} +function nscvRouteGap(a: number, sizeA: number, b: number, sizeB: number): number { + const f = Math.fround, endA = f(a + sizeA), endB = f(b + sizeB); + if (nscvSurfaceMin(endA, endB) > nscvSurfaceMax(a, b)) return 0; + return b >= endA ? f(b - endA) : f(a - endB); +} +function nscvRouteSpatial(nodes: NscRouteNode[], current: number, direction: number): number { + const missing = 4294967295, f = Math.fround; + if (!nscvRouteFocus(nodes, current, false)) return missing; + const a = nodes[current]!.frame, ax = f(a.x + f(a.width / 2)), ay = f(a.y + f(a.height / 2)); + let best = missing, bestScore = Infinity; + for (let i = 0; i < nodes.length; i++) { + if (i === current || !nscvRouteFocus(nodes, i, false)) continue; + const b = nodes[i]!.frame; + if (!(direction === 2 ? f(b.x + b.width) <= ax : direction === 3 ? b.x >= ax : direction === 4 ? f(b.y + b.height) <= ay : b.y >= ay)) continue; + const bx = f(b.x + f(b.width / 2)), by = f(b.y + f(b.height / 2)), dx = Math.abs(f(bx - ax)), dy = Math.abs(f(by - ay)); + const score = direction === 2 || direction === 3 ? f(f(f(dx * 4096) + f(nscvRouteGap(a.y, a.height, b.y, b.height) * 4096)) + dy) : f(f(f(dy * 4096) + f(nscvRouteGap(a.x, a.width, b.x, b.width) * 4096)) + dx); + if (score < bestScore || score === bestScore && (best === missing || i < best)) { best = i; bestScore = score; } + } + return best; +} +function nscvWidgetRouting(request: Uint8Array): Uint8Array { + if (request.length < 64 || request[0] !== 24 || request[1] !== 1 || request[2]! > 13) throw new Error("invalid widget routing header"); + const wire = new DataView(request.buffer, request.byteOffset, request.byteLength), op = request[2]!, param = request[3]!; + const count = wire.getUint32(4, true), subject = wire.getUint32(8, true), capacity = wire.getUint32(12, true), missing = 4294967295; + if (request.length !== 64 + count * 96 || (op >= 2 && op <= 5 || op >= 12) && subject !== missing && subject >= count || + (op === 6 || op === 10 ? param > 5 : op === 8 ? param > 1 : op === 3 ? param > 7 : param !== 0)) throw new Error("invalid widget routing query"); + if (wire.getUint32(48, true) > 1) throw new Error("invalid routing identity presence"); + for (let at = 52; at < 64; at += 4) if (wire.getUint32(at, true) !== 0) throw new Error("invalid widget routing reserved bytes"); + const nodes: NscRouteNode[] = [], layers: number[] = []; + for (let i = 0; i < 4; i++) layers.push(wire.getInt32(32 + i * 4, true)); + for (let i = 0; i < count; i++) { + const at = 64 + i * 96, kind = wire.getUint32(at, true), role = wire.getUint32(at + 4, true), parent = wire.getUint32(at + 8, true), flags = wire.getUint32(at + 24, true), actions = wire.getUint32(at + 28, true); + // Solved trees are preorder. Requiring an earlier parent also excludes + // cycles before any recursive or ancestor walk can begin. + if (kind > 62 || role > 26 || parent !== missing && parent >= i || flags > 2047 || actions > 2047) throw new Error("invalid widget routing node facts"); + for (let r = 80; r < 96; r += 4) if (wire.getUint32(at + r, true) !== 0) throw new Error("invalid widget routing node reserved bytes"); + const transform: number[] = []; for (let t = 0; t < 6; t++) transform.push(wire.getFloat32(at + 56 + t * 4, true)); + nodes.push({ kind, role, parent, depth: wire.getUint32(at + 12, true), lo: wire.getUint32(at + 16, true), hi: wire.getUint32(at + 20, true), flags, actions, + value: wire.getFloat32(at + 32, true), layer: wire.getInt32(at + 36, true), transform, + frame: nscvSurfaceNormalize({ x: wire.getFloat32(at + 40, true), y: wire.getFloat32(at + 44, true), width: wire.getFloat32(at + 48, true), height: wire.getFloat32(at + 52, true) }) }); + } + const lo = wire.getUint32(16, true), hi = wire.getUint32(20, true), x = wire.getFloat32(24, true), y = wire.getFloat32(28, true); + const lookup = (): number => { if (lo === 0 && hi === 0) return missing; for (let i = 0; i < count; i++) if (nodes[i]!.lo === lo && nodes[i]!.hi === hi) return i; return missing; }; + let target = missing, press = missing, status = 0, keepHit = false; + const entries: NscRouteEntry[] = []; + if (op <= 1) target = nscvRouteHitTree(nodes, x, y, layers, op === 1); + else if (op === 2) target = nscvRoutePress(nodes, subject); + else if (op === 3 && subject < count) { + // Hover receives a typed hit which may carry caller-supplied role, + // state and geometry. Preserve that complete record when the reference + // keeps it, rather than reconstructing it from a different node record. + if ((param & 1) !== 0 && (param & 2) === 0) { target = subject; keepHit = true; } + else { const claimed = nscvRoutePress(nodes, subject); keepHit = claimed === missing; target = keepHit ? subject : claimed; + const cell = keepHit ? (param & 4) !== 0 : nodes[target]!.kind === 44; + if (cell) { let p = nodes[target]!.parent; while (p < count) { if (nodes[p]!.kind === 43) { target = p; keepHit = false; break; } p = nodes[p]!.parent; } } + } + } else if (op === 4) { + const chain: number[] = []; let i = subject; + while (i < count) { const n = nodes[i]!; if (nscvRouteLive(n) && (n.flags & 32) !== 0 && chain.length < 32) chain.push(i); i = n.parent; } + for (let c = 0; c < Math.min(chain.length, capacity); c++) entries.push({ index: chain[chain.length - 1 - c]!, phase: 0 }); + } else if (op === 5) { + let i = subject; while (i < count) { const n = nodes[i]!; if (nscvRouteClaims(n)) break; if (nscvRouteLive(n) && (n.flags & 64) !== 0) { target = i; break; } i = n.parent; } + } else if (op === 6) { + if (wire.getUint32(48, true) === 1 && (param === 2 || param === 3 || param === 4)) { + const i = lookup(); if (i < count && nscvRouteHit(nodes[i]!, false) && !nscvRouteHidden(nodes, i) && !nscvRouteConcealed(nodes, i) && nscvRouteVisible(nodes, i, false, 0, 0)) target = i; + } else target = nscvRouteHitTree(nodes, x, y, layers, false); + press = nscvRoutePress(nodes, target); + } else if (op === 7 || op === 11) { const i = lookup(); if (i < count && nscvRouteFocus(nodes, i, false)) target = i; } + else if (op === 8 && param === 1) { + for (let i = count - 1; i >= 0; i--) { const n = nodes[i]!; if (nscvRouteLive(n) && (n.flags & 1) === 0 && (n.actions & 512) !== 0 && !nscvRouteHidden(nodes, i) && !nscvRouteConcealed(nodes, i) && nscvRouteContains(n.frame, x, y) && nscvRouteVisible(nodes, i, true, x, y)) { target = i; break; } } + } else if (op === 9) { + let i = lookup(); while (i < count) { const n = nodes[i]!; + if (nscvRouteLive(n) && (n.flags & 1) === 0 && ((n.actions & 256) !== 0 || n.kind === 16 || n.kind === 58)) { if (!nscvRouteHidden(nodes, i) && !nscvRouteConcealed(nodes, i) && nscvRouteVisible(nodes, i, false, 0, 0)) target = i; break; } i = n.parent; + } + } else if (op === 10) { + const current = lookup(); + if (param >= 2) { if (current < count) target = nscvRouteSpatial(nodes, current, param); } + else if (param === 0) { + for (let i = current === missing ? 0 : current + 1; i < count; i++) if (nscvRouteFocus(nodes, i, false)) { target = i; break; } + if (target === missing) for (let i = 0; i < (current === missing ? count : current); i++) if (nscvRouteFocus(nodes, i, false)) { target = i; break; } + } else { + for (let i = (current === missing ? count : current) - 1; i >= 0; i--) if (nscvRouteFocus(nodes, i, false)) { target = i; break; } + if (target === missing) for (let i = count - 1; i >= (current === missing ? 0 : current + 1); i--) if (nscvRouteFocus(nodes, i, false)) { target = i; break; } + } + } else if ((op === 12 || op === 13) && subject < count && nscvRouteFocus(nodes, subject, op === 13)) target = subject; + if (op >= 6 && op <= 9 && target < count) { + const path: number[] = []; let i = target; + while (i < count) { if (path.length >= 32) { status = 1; break; } path.push(i); i = nodes[i]!.parent; } + if (status === 0) { + const route: NscRouteEntry[] = []; + for (let p = path.length - 1; p > 0; p--) route.push({ index: path[p]!, phase: 0 }); + route.push({ index: target, phase: 1 }); + for (let p = 1; p < path.length; p++) route.push({ index: path[p]!, phase: 2 }); + for (const e of route) { if (entries.length >= capacity) { status = 2; break; } entries.push(e); } + } + } + const result = new Uint8Array(24 + entries.length * 8), out = new DataView(result.buffer); + result.set([1, op, status, keepHit ? 1 : 0]); out.setUint32(4, target, true); out.setUint32(8, press, true); out.setUint32(12, entries.length, true); + for (let i = 0; i < entries.length; i++) { out.setUint32(24 + i * 8, entries[i]!.index, true); out.setUint32(28 + i * 8, entries[i]!.phase, true); } + return result; +} diff --git a/packages/core/test/view_frontend.test.ts b/packages/core/test/view_frontend.test.ts index 2416e6c5e..355ab8189 100644 --- a/packages/core/test/view_frontend.test.ts +++ b/packages/core/test/view_frontend.test.ts @@ -194,7 +194,7 @@ test("the view frontend and compiled portable component bundle typecheck", () => }; // Production joins these policies in one module before compiling the view. const bundlePath = new URL("../src/view_components_typecheck.ts", import.meta.url).pathname; - const bundle = ["view_components.ts", "runtime_policy.ts", "control_appearance.ts", "component_construction.ts", "widget_motion.ts", "component_composition.ts", "code_content.ts", "chart_content.ts", "markdown_content.ts", "widget_audits.ts", "stream_policy.ts"] + const bundle = ["view_components.ts", "runtime_policy.ts", "control_appearance.ts", "component_construction.ts", "widget_motion.ts", "component_composition.ts", "code_content.ts", "chart_content.ts", "markdown_content.ts", "widget_audits.ts", "widget_routing.ts", "stream_policy.ts"] .map(name => readFileSync(new URL(`../src/${name}`, import.meta.url), "utf8")).join("\n"); const host = ts.createCompilerHost(options); const readSource = host.getSourceFile.bind(host); diff --git a/packages/core/test/widget_routing.test.ts b/packages/core/test/widget_routing.test.ts new file mode 100644 index 000000000..fb62b1cb0 --- /dev/null +++ b/packages/core/test/widget_routing.test.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import { stripTypeScriptTypes } from "node:module"; +const source = ["runtime_policy.ts", "widget_routing.ts"].map(p => readFileSync(new URL(`../src/${p}`, import.meta.url), "utf8")).join("\n"); +const { nscvWidgetRouting: route } = await import(`data:text/javascript;base64,${Buffer.from(stripTypeScriptTypes(source) + "\nexport { nscvWidgetRouting };").toString("base64")}`); +const missing = 0xffffffff; +const wire = (b: Uint8Array) => new DataView(b.buffer, b.byteOffset, b.byteLength); +interface Node { kind?: number; role?: number; parent?: number; depth?: number; id?: bigint; flags?: number; actions?: number; value?: number; layer?: number; frame?: number[]; transform?: number[] } +function packet(op: number, nodes: Node[], options: { param?: number; subject?: number; id?: bigint; point?: number[]; capacity?: number; layers?: number[] } = {}) { + const b = new Uint8Array(64 + nodes.length * 96), w = wire(b); + b.set([24, 1, op, options.param ?? 0]); w.setUint32(4, nodes.length, true); w.setUint32(8, options.subject ?? missing, true); w.setUint32(12, options.capacity ?? 63, true); + w.setUint32(48, Number(options.id !== undefined), true); w.setBigUint64(16, options.id ?? 0n, true); (options.point ?? [5, 5]).forEach((v, i) => w.setFloat32(24 + i * 4, v, true)); (options.layers ?? [0, 10, 20, 30]).forEach((v, i) => w.setInt32(32 + i * 4, v, true)); + nodes.forEach((n, i) => { const p = 64 + i * 96; + for (const [at, v] of [[0, n.kind ?? 31], [4, n.role ?? 0], [8, n.parent ?? missing], [12, n.depth ?? i], [24, n.flags ?? 0], [28, n.actions ?? 0]]) w.setUint32(p + at!, v!, true); + w.setBigUint64(p + 16, n.id ?? BigInt(i + 1), true); w.setFloat32(p + 32, n.value ?? 0, true); w.setInt32(p + 36, n.layer ?? 0, true); + (n.frame ?? [0, 0, 30, 30]).forEach((v, t) => w.setFloat32(p + 40 + t * 4, v, true)); (n.transform ?? [1, 0, 0, 1, 0, 0]).forEach((v, t) => w.setFloat32(p + 56 + t * 4, v, true)); + }); return b; +} +function result(b: Uint8Array) { const w = wire(b), entries: number[][] = []; for (let i = 0; i < w.getUint32(12, true); i++) entries.push([w.getUint32(24 + i * 8, true), w.getUint32(28 + i * 8, true)]); return { status: b[2], target: w.getUint32(4, true), press: w.getUint32(8, true), entries }; } +test("pointer capture phases preserve exact identities and never retarget a missing capture", () => { + const id = 0xfedcba9876543210n, nodes = [{ kind: 1 }, { parent: 0, id }]; + for (const param of [2, 3, 4]) { + assert.equal(result(route(packet(6, nodes, { id, param, point: [1000, 1000] }))).target, 1); + assert.equal(result(route(packet(6, nodes, { id: id - 1n, param }))).target, missing); + assert.equal(result(route(packet(6, nodes, { id: 0n, param }))).target, missing); + } + for (const param of [0, 1, 5]) assert.equal(result(route(packet(6, nodes, { id, param, point: [1000, 1000] }))).target, missing); + assert.equal(result(route(packet(6, nodes, { param: 2 }))).target, 1); +}); +test("complete event paths preserve phase order, depth errors and the written overflow prefix", () => { + const nodes = [{ kind: 1 }, { kind: 1, parent: 0 }, { parent: 1 }]; + const expected = [[0, 0], [1, 0], [2, 1], [1, 2], [0, 2]]; + assert.deepEqual(result(route(packet(6, nodes))), { status: 0, target: 2, press: 2, entries: expected }); + for (let capacity = 0; capacity < 5; capacity++) assert.deepEqual(result(route(packet(6, nodes, { capacity }))).entries, expected.slice(0, capacity)); + assert.equal(result(route(packet(6, nodes, { capacity: 4 }))).status, 2); + const deep = Array.from({ length: 33 }, (_, i) => ({ parent: i === 0 ? missing : i - 1 })); + assert.deepEqual(result(route(packet(6, deep))), { status: 1, target: 32, press: 32, entries: [] }); + deep.pop(); assert.equal(result(route(packet(6, deep))).entries.length, 63); +}); +test("hover listeners preserve containment without taking interactive hits or claiming window drag", () => { + const nodes = [{ kind: 1, flags: 64 | 32 }, { kind: 26, parent: 0 }, { kind: 1, flags: 32 }]; + assert.equal(result(route(packet(0, nodes))).target, 1); assert.equal(result(route(packet(1, nodes))).target, 2); + assert.equal(result(route(packet(5, nodes, { subject: 1 }))).target, 0); + assert.equal(result(route(packet(2, nodes, { subject: 1 }))).target, missing); + nodes[1]!.kind = 31; assert.equal(result(route(packet(5, nodes, { subject: 1 }))).target, missing); + const chain = Array.from({ length: 40 }, (_, i) => ({ kind: 1, flags: 32, parent: i === 0 ? missing : i - 1 })); + assert.deepEqual(result(route(packet(4, chain, { subject: 39, capacity: 3 }))).entries, [[8, 0], [9, 0], [10, 0]]); +}); +test("window surfaces use effective modal layers, mount ties, explicit layers and hidden ancestry", () => { + const nodes: Node[] = [{ kind: 19 }, { kind: 40, parent: 0, flags: 8 }, { flags: 8 }]; + assert.equal(result(route(packet(0, nodes))).target, 0); // tooltip is decoration, modal still wins + nodes[1]!.kind = 31; assert.equal(result(route(packet(0, nodes))).target, 1); + nodes[2]!.flags = 8 | 512; nodes[2]!.layer = 21; assert.equal(result(route(packet(0, nodes))).target, 2); + nodes[0]!.flags = 1; nodes[2]!.flags = 8; assert.equal(result(route(packet(0, nodes))).target, 2); +}); +test("hit geometry is transformed locally and uses half-open normalized bounds", () => { + const nodes: Node[] = [{ frame: [30, 30, -30, -30], transform: [1, 0, 0, 1, 40, 0] }]; + assert.equal(result(route(packet(0, nodes, { point: [40, 0] }))).target, 0); + assert.equal(result(route(packet(0, nodes, { point: [70, 0] }))).target, missing); + nodes[0]!.transform = [0, 0, 0, 1, 0, 0]; assert.equal(result(route(packet(0, nodes))).target, missing); +}); +test("focus wraps without repeating current, preserves logical clipped targets and spatial ties", () => { + const nodes: Node[] = [{ kind: 22, flags: 16, frame: [0, 0, 100, 100] }, { parent: 0, frame: [10, 10, 20, 20] }, { parent: 0, frame: [60, 10, 20, 20] }, { parent: 0, frame: [60, 10, 20, 20] }, { parent: 0, frame: [150, 10, 20, 20] }]; + assert.equal(result(route(packet(10, nodes, { id: 2n, param: 3 }))).target, 2); + assert.equal(result(route(packet(10, nodes, { id: 4n }))).target, 1); + assert.equal(result(route(packet(10, nodes, { id: 2n, param: 1 }))).target, 3); + assert.equal(result(route(packet(12, nodes, { subject: 4 }))).target, missing); + assert.equal(result(route(packet(13, nodes, { subject: 4 }))).target, 4); + assert.equal(result(route(packet(10, [{ id: 0xffffffffffffffffn }], { id: 0xffffffffffffffffn }))).target, missing); +}); +test("file-drop reverses mounted order while drag falls through to its eligible ancestor", () => { + const nodes: Node[] = [{ kind: 1, actions: 256 }, { kind: 26, parent: 0 }, { kind: 1, actions: 512 }, { kind: 1, actions: 512 }]; + assert.equal(result(route(packet(9, nodes, { id: 2n }))).target, 0); + assert.equal(result(route(packet(8, nodes, { param: 1 }))).target, 3); + assert.equal(result(route(packet(8, nodes))).target, missing); + nodes[3]!.flags = 2; assert.equal(result(route(packet(8, nodes, { param: 1 }))).target, 2); +}); +test("hover keeps a caller's complete link or unclaimed hit and reconstructs claiming ancestors", () => { + const nodes: Node[] = [{ kind: 1 }, { kind: 26, parent: 0 }]; + let out = route(packet(3, nodes, { subject: 1, param: 1 })); assert.equal(out[3], 1); assert.equal(result(out).target, 1); + nodes[0]!.actions = 2; + out = route(packet(3, nodes, { subject: 1, param: 1 })); assert.equal(out[3], 1); assert.equal(result(out).target, 1); + out = route(packet(3, nodes, { subject: 1, param: 3 })); assert.equal(out[3], 0); assert.equal(result(out).target, 0); + nodes[0]!.actions = 0; + out = route(packet(3, nodes, { subject: 1 })); assert.equal(out[3], 1); assert.equal(result(out).target, 1); + nodes[0]!.kind = 43; + out = route(packet(3, nodes, { subject: 1, param: 4 })); assert.equal(out[3], 0); assert.equal(result(out).target, 0); +}); +test("requests reject truncation, trailing bytes, cycles, future parents and unknown facts", () => { + const valid = packet(0, [{}]); + for (let i = 0; i < valid.length; i++) assert.throws(() => route(valid.subarray(0, i))); + const trailing = new Uint8Array(valid.length + 1); trailing.set(valid); assert.throws(() => route(trailing)); + for (const [at, value] of [[0, 0], [1, 2], [2, 14], [3, 1], [48, 2], [52, 1], [64, 63], [68, 27], [72, 0], [88, 2048], [92, 2048], [144, 1]]) { const bad = valid.slice(); if (at! < 4) bad[at!] = value!; else wire(bad).setUint32(at!, value!, true); assert.throws(() => route(bad), `field ${at}`); } +}); diff --git a/src/primitives/canvas/root.zig b/src/primitives/canvas/root.zig index 14d2e7704..cbc0f27a1 100644 --- a/src/primitives/canvas/root.zig +++ b/src/primitives/canvas/root.zig @@ -899,6 +899,7 @@ pub const pseudo_locale_text_expansion = @import("layout_audit.zig").pseudo_loca /// (the markup lint in ui_markup.zig is the source-level half). The /// sweep helper (`canvas.a11y.expectA11yAuditSweepClean`, re-exported /// below) is adopted by the example suites like the layout audit's. +pub const widget_routing_policy = @import("widget_routing_policy.zig"); pub const widget_audit_policy = @import("widget_audit_policy.zig"); pub const a11y = @import("a11y_audit.zig"); pub const expectA11yAuditSweepClean = a11y.expectA11yAuditSweepClean; diff --git a/src/primitives/canvas/tokens.zig b/src/primitives/canvas/tokens.zig index af5d2a848..722f84023 100644 --- a/src/primitives/canvas/tokens.zig +++ b/src/primitives/canvas/tokens.zig @@ -1914,6 +1914,8 @@ pub const DesignTokens = struct { widget_motion_policy: ?*const fn ([]const u8, []u8) usize = null, /// Portable accessibility/layout audit rules over native measurement facts. widget_audit_policy: ?*const fn ([]const u8, []u8) usize = null, + /// Portable input routing and focus over copied solved-tree facts. + widget_routing_policy: ?*const fn ([]const u8, []u8) usize = null, code_content_policy: ?*const fn ([]const u8, []u8) usize = null, pub fn theme(options: ThemeOptions) DesignTokens { diff --git a/src/primitives/canvas/widget_routing.zig b/src/primitives/canvas/widget_routing.zig index d8fd9af97..20e6dc590 100644 --- a/src/primitives/canvas/widget_routing.zig +++ b/src/primitives/canvas/widget_routing.zig @@ -8,6 +8,7 @@ const event_model = @import("events.zig"); const equality_model = @import("equality.zig"); const widget_tree = @import("widget_tree.zig"); const widget_access = @import("widget_access.zig"); +const portable = @import("widget_routing_policy.zig"); const Error = canvas.Error; const ObjectId = canvas.ObjectId; @@ -55,6 +56,10 @@ pub fn hitTestWidgetLayout(layout: anytype, point: geometry.PointF, tokens: Desi } pub fn hitTestWidgetLayoutWithPolicy(layout: anytype, point: geometry.PointF, tokens: DesignTokens, comptime policy: HitTestPolicy) ?WidgetHit { + if (tokens.widget_routing_policy orelse portable.owner(layout)) |owner| { + const result = portable.query(layout, owner, if (policy == .interactive) 0 else 1, 0, null, null, point, tokens, 0, {}); + return hitFromPortableResult(layout, result.target); + } // Window-level surfaces paint in the late z-pass — topmost — so they // hit-test FIRST, in reverse window-surface paint order. const surface_count = widget_tree.widgetLayoutWindowSurfaceCount(layout); @@ -136,6 +141,7 @@ fn widgetHitFromNode(node: WidgetLayoutNode, index: usize) WidgetHit { /// nothing on the path claims — the press dispatches to no one, exactly /// like a click on dead space. pub fn widgetPressTargetIndexFromNode(layout: anytype, node_index: usize) ?usize { + if (portable.owner(layout)) |owner| return portable.query(layout, owner, 2, 0, node_index, null, .{}, .{}, 0, {}).target; var current: ?usize = node_index; while (current) |index| { if (index >= layout.nodes.len) return null; @@ -160,6 +166,11 @@ pub fn widgetPressTargetForHit(layout: anytype, hit: WidgetHit) ?WidgetHit { /// pressable ancestor), and a hit with no claiming ancestor keeps itself /// (static text keeps its selection affordance). pub fn widgetHoverTargetForHit(layout: anytype, hit: WidgetHit) WidgetHit { + if (portable.owner(layout)) |owner| { + const facts: u8 = @as(u8, @intFromBool(hit.role == .link)) | (@as(u8, @intFromBool(hit.state.disabled)) << 1) | (@as(u8, @intFromBool(hit.kind == .data_cell)) << 2); + const result = portable.query(layout, owner, 3, facts, hit.index, null, .{}, .{}, 0, {}); + return if (result.keep_hit) hit else hitFromPortableResult(layout, result.target) orelse hit; + } if (hit.role == .link and !hit.state.disabled) return hit; const target = widgetPressTargetForHit(layout, hit) orelse hit; // A table row hovers as ONE unit: a hit that resolves to a cell (a @@ -185,6 +196,11 @@ pub fn widgetHoverTargetForHit(layout: anytype, hit: WidgetHit) WidgetHit { /// exactly like the browser pair this models. The chain is empty for /// apps that bind no hover handlers, so unbound trees pay nothing. pub fn widgetHoverMsgChainFromNode(layout: anytype, node_index: usize, output: []ObjectId) []const ObjectId { + if (portable.owner(layout)) |owner| { + const result = portable.query(layout, owner, 4, 0, node_index, null, .{}, .{}, output.len, {}); + for (0..result.len) |i| output[i] = layout.nodes[result.indices[i]].widget.id; + return output[0..result.len]; + } var chain: [max_widget_depth]ObjectId = undefined; var chain_len: usize = 0; var current: ?usize = node_index; @@ -222,6 +238,7 @@ fn widgetAncestorHitOfKind(layout: anytype, node_index: usize, kind: WidgetKind) /// walk. A widget that both claims presses and marks `window_drag` /// keeps its press — authored handlers outrank the drag surface. pub fn widgetWindowDragTargetIndexFromNode(layout: anytype, node_index: usize) ?usize { + if (portable.owner(layout)) |owner| return portable.query(layout, owner, 5, 0, node_index, null, .{}, .{}, 0, {}).target; var current: ?usize = node_index; while (current) |index| { if (index >= layout.nodes.len) return null; @@ -264,6 +281,19 @@ fn isWidgetFrameVisibleInWidgetAncestors(layout: anytype, node_index: usize) boo } pub fn routeWidgetPointerEvent(layout: anytype, event: WidgetPointerEvent, tokens: DesignTokens, output: []WidgetEventRouteEntry) Error!WidgetEventRoute { + if (tokens.widget_routing_policy orelse portable.owner(layout)) |owner| { + const phase: u8 = switch (event.phase) { + .hover => 0, + .down => 1, + .move => 2, + .up => 3, + .cancel => 4, + .wheel => 5, + }; + const result = portable.query(layout, owner, 6, phase, null, event.captured_id, event.point, tokens, output.len, {}); + const entries = try result.copy(layout, output); + return .{ .target = hitFromPortableResult(layout, result.target), .press_target = hitFromPortableResult(layout, result.press), .entries = entries }; + } const target = if (eventUsesPointerCapture(event)) blk: { break :blk capturedWidgetPointerTarget(layout, event) orelse return .{ .entries = output[0..0] }; } else hitTestWidgetLayout(layout, event.point, tokens) orelse return .{ .entries = output[0..0] }; @@ -302,6 +332,11 @@ fn widgetPointerTargetById(layout: anytype, id: ObjectId) ?WidgetHit { } pub fn routeWidgetKeyboardEvent(layout: anytype, event: WidgetKeyboardEvent, output: []WidgetEventRouteEntry, scroll_semantics_fn: anytype) Error!WidgetKeyboardRoute { + if (portable.owner(layout)) |owner| { + const result = portable.query(layout, owner, 7, 0, null, event.focused_id, .{}, .{}, output.len, scroll_semantics_fn); + const entries = try result.copy(layout, output); + return .{ .target = focusFromPortableResult(layout, result.target), .entries = entries }; + } const focused_id = event.focused_id orelse return .{ .entries = output[0..0] }; const target_index = widgetIndexById(layout, focused_id) orelse return .{ .entries = output[0..0] }; const target = focusTargetFromLayoutNode(layout, target_index, scroll_semantics_fn) orelse return .{ .entries = output[0..0] }; @@ -310,6 +345,11 @@ pub fn routeWidgetKeyboardEvent(layout: anytype, event: WidgetKeyboardEvent, out } pub fn routeWidgetFileDropEvent(layout: anytype, event: WidgetFileDropEvent, output: []WidgetEventRouteEntry) Error!WidgetEventRoute { + if (portable.owner(layout)) |owner| { + const result = portable.query(layout, owner, 8, @intFromBool(event.paths.len != 0), null, null, event.point, .{}, output.len, {}); + const entries = try result.copy(layout, output); + return .{ .target = hitFromPortableResult(layout, result.target), .entries = entries }; + } if (event.paths.len == 0) return .{ .entries = output[0..0] }; const target_index = widgetDropTargetIndexAtPoint(layout, event.point) orelse return .{ .entries = output[0..0] }; const entries = try routeWidgetEventPath(layout, target_index, output); @@ -317,6 +357,11 @@ pub fn routeWidgetFileDropEvent(layout: anytype, event: WidgetFileDropEvent, out } pub fn routeWidgetDragEvent(layout: anytype, event: WidgetDragEvent, output: []WidgetEventRouteEntry) Error!WidgetEventRoute { + if (portable.owner(layout)) |owner| { + const result = portable.query(layout, owner, 9, 0, null, event.source_id, .{}, .{}, output.len, {}); + const entries = try result.copy(layout, output); + return .{ .target = hitFromPortableResult(layout, result.target), .entries = entries }; + } const target_index = widgetDragSourceIndex(layout, event.source_id) orelse return .{ .entries = output[0..0] }; const entries = try routeWidgetEventPath(layout, target_index, output); return .{ .target = widgetHitFromNode(layout.nodes[target_index], target_index), .entries = entries }; @@ -399,6 +444,17 @@ fn appendWidgetEventRouteEntry( } pub fn focusWidgetTarget(layout: anytype, current_id: ?ObjectId, direction: WidgetFocusDirection, scroll_semantics_fn: anytype) ?WidgetFocusTarget { + if (portable.owner(layout)) |owner| { + const param: u8 = switch (direction) { + .forward => 0, + .backward => 1, + .left => 2, + .right => 3, + .up => 4, + .down => 5, + }; + return focusFromPortableResult(layout, portable.query(layout, owner, 10, param, null, current_id, .{}, .{}, 0, scroll_semantics_fn).target); + } if (layout.nodes.len == 0) return null; const current_index = if (current_id) |id| widgetIndexById(layout, id) else null; return switch (direction) { @@ -409,12 +465,14 @@ pub fn focusWidgetTarget(layout: anytype, current_id: ?ObjectId, direction: Widg } pub fn focusWidgetTargetById(layout: anytype, id: ObjectId, scroll_semantics_fn: anytype) ?WidgetFocusTarget { + if (portable.owner(layout)) |owner| return focusFromPortableResult(layout, portable.query(layout, owner, 11, 0, null, id, .{}, .{}, 0, scroll_semantics_fn).target); const index = widgetIndexById(layout, id) orelse return null; return focusWidgetTargetAtIndex(layout, index, scroll_semantics_fn); } /// Exact visible-focus capability for a retained index, without an ID scan. pub fn focusWidgetTargetAtIndex(layout: anytype, index: usize, scroll_semantics_fn: anytype) ?WidgetFocusTarget { + if (portable.owner(layout)) |owner| return focusFromPortableResult(layout, portable.query(layout, owner, 12, 0, index, null, .{}, .{}, 0, scroll_semantics_fn).target); if (index >= layout.nodes.len) return null; return focusTargetFromLayoutNode(layout, index, scroll_semantics_fn); } @@ -424,6 +482,7 @@ pub fn focusWidgetTargetAtIndex(layout: anytype, index: usize, scroll_semantics_ /// keeping the exact same hidden, disclosure, disabled, and scroll-semantic /// eligibility as `focusWidgetTargetById`. pub fn logicalFocusWidgetTargetAtIndex(layout: anytype, index: usize, scroll_semantics_fn: anytype) ?WidgetFocusTarget { + if (portable.owner(layout)) |owner| return focusFromPortableResult(layout, portable.query(layout, owner, 13, 0, index, null, .{}, .{}, 0, scroll_semantics_fn).target); if (index >= layout.nodes.len) return null; if (isWidgetHiddenInAncestors(layout, index)) return null; if (widget_tree.isWidgetConcealedByDisclosure(layout, index)) return null; @@ -542,3 +601,13 @@ fn rectsOverlapX(a: geometry.RectF, b: geometry.RectF) bool { fn rectsOverlapY(a: geometry.RectF, b: geometry.RectF) bool { return @min(a.maxY(), b.maxY()) > @max(a.y, b.y); } + +fn hitFromPortableResult(layout: anytype, result: ?usize) ?WidgetHit { + const index = result orelse return null; + return widgetHitFromNode(layout.nodes[index], index); +} +fn focusFromPortableResult(layout: anytype, result: ?usize) ?WidgetFocusTarget { + const index = result orelse return null; + const node = layout.nodes[index]; + return .{ .id = node.widget.id, .kind = node.widget.kind, .bounds = node.frame, .index = index, .state = node.widget.state }; +} diff --git a/src/primitives/canvas/widget_routing_policy.zig b/src/primitives/canvas/widget_routing_policy.zig new file mode 100644 index 000000000..66164b17a --- /dev/null +++ b/src/primitives/canvas/widget_routing_policy.zig @@ -0,0 +1,121 @@ +//! Copied routing boundary. Native owns request/result/caller storage and +//! reconstructs typed records; the portable owner selects every target/path. +const std = @import("std"); +const geometry = @import("geometry"); +const widgets = @import("widgets.zig"); +const events = @import("events.zig"); +const tokens_model = @import("tokens.zig"); +pub const Policy = @import("surface_layout_policy.zig").Policy; +const missing = std.math.maxInt(u32); +const roles = [_]widgets.WidgetRole{ .none, .group, .text, .link, .image, .button, .textbox, .tooltip, .dialog, .menu, .menuitem, .list, .listitem, .row, .grid, .gridcell, .tab, .checkbox, .radio, .radiogroup, .switch_control, .slider, .progressbar, .chart, .tree, .treeitem, .separator }; +comptime { + if (roles.len != @typeInfo(widgets.WidgetRole).@"enum".fields.len) @compileError("update routing role codes"); +} +pub fn owner(layout: anytype) ?Policy { + const T = switch (@typeInfo(@TypeOf(layout))) { + .pointer => |p| p.child, + else => @TypeOf(layout), + }; + if (comptime @hasField(T, "routing_policy")) return layout.routing_policy; + return null; +} +fn roleCode(role: widgets.WidgetRole) u32 { + for (roles, 0..) |value, i| if (value == role) return @intCast(i); + unreachable; +} +fn put(bytes: []u8, at: usize, value: u32) void { + std.mem.writeInt(u32, bytes[at..][0..4], value, .little); +} +fn float(bytes: []u8, at: usize, value: f32) void { + put(bytes, at, @bitCast(value)); +} +fn word(bytes: []const u8, at: usize) u32 { + return std.mem.readInt(u32, bytes[at..][0..4], .little); +} +pub const Result = struct { + status: u8, + keep_hit: bool, + target: ?usize, + press: ?usize, + len: usize, + indices: [63]usize = undefined, + phases: [63]events.WidgetEventPhase = undefined, + + pub fn copy(self: Result, layout: anytype, output: []events.WidgetEventRouteEntry) @import("root.zig").Error![]const events.WidgetEventRouteEntry { + for (0..self.len) |i| { + const index = self.indices[i]; + const node = layout.nodes[index]; + output[i] = .{ .phase = self.phases[i], .node_index = index, .id = node.widget.id, .kind = node.widget.kind, .bounds = node.frame }; + } + // The reference preserves the already-written prefix on overflow. + if (self.status == 1) return error.WidgetDepthExceeded; + if (self.status == 2) return error.WidgetEventRouteListFull; + return output[0..self.len]; + } +}; +fn readIndex(value: u32, count: usize) ?usize { + if (value == missing) return null; + if (value >= count) @panic("invalid compiled routing index"); + return value; +} +/// op is a protocol query, param is its phase/direction. Scroll facts are +/// observed only for focus queries, through the existing semantic owner. +pub fn query(layout: anytype, policy: Policy, comptime op: u8, param: u8, subject: ?usize, id: ?u64, point: geometry.PointF, tokens: tokens_model.DesignTokens, capacity: usize, scroll_semantics_fn: anytype) Result { + const allocator = std.heap.page_allocator; + const count = std.math.cast(u32, layout.nodes.len) orelse @panic("routing node capacity"); + const length = std.math.add(usize, 64, std.math.mul(usize, count, 96) catch @panic("routing byte capacity")) catch @panic("routing byte capacity"); + const request = allocator.alloc(u8, length) catch @panic("routing request allocation"); + defer allocator.free(request); + @memset(request, 0); + request[0..4].* = .{ 24, 1, op, param }; + put(request, 4, count); + put(request, 8, if (subject) |i| (if (i < count) @as(u32, @intCast(i)) else missing) else missing); + put(request, 12, @intCast(@min(capacity, std.math.maxInt(u32)))); + std.mem.writeInt(u64, request[16..24], id orelse 0, .little); + float(request, 24, point.x); + float(request, 28, point.y); + // Some(0) is a capture request that cannot resolve; it is not null. + put(request, 48, @intFromBool(id != null)); + for ([_]i32{ tokens.layer.base, tokens.layer.overlay, tokens.layer.modal, tokens.layer.floating }, 0..) |layer, i| put(request, 32 + i * 4, @bitCast(layer)); + for (layout.nodes, 0..) |node, i| { + const n = node.widget; + const bytes = request[64 + i * 96 ..][0..96]; + put(bytes, 0, widgets.widgetKindCode(n.kind)); + put(bytes, 4, roleCode(n.semantics.role)); + put(bytes, 8, if (node.parent_index) |p| (std.math.cast(u32, p) orelse missing) else missing); + put(bytes, 12, std.math.cast(u32, node.depth) orelse @panic("routing depth capacity")); + std.mem.writeInt(u64, bytes[16..24], n.id, .little); + // Authored flags only: no native target/visibility/focus verdicts. + var flags: u32 = @intFromBool(n.semantics.hidden) | (@as(u32, @intFromBool(n.state.disabled)) << 1) | + (@as(u32, @intFromBool(n.state.selected)) << 2) | (@as(u32, @intFromBool(n.layout.anchor != null)) << 3) | + (@as(u32, @intFromBool(n.layout.clip_content)) << 4) | (@as(u32, @intFromBool(n.hover_msgs)) << 5) | + (@as(u32, @intFromBool(n.window_drag)) << 6) | (@as(u32, @intFromBool(n.semantics.focusable)) << 7) | + (@as(u32, @intFromBool(n.layer != null)) << 9) | (@as(u32, @intFromBool(n.chart.hover_details)) << 10); + if (comptime op == 7 or op >= 10) { + if (scroll_semantics_fn(layout, i).scrollable) flags |= 256; + } + put(bytes, 24, flags); + put(bytes, 28, events.semanticActionBits(n.semantics.actions)); + float(bytes, 32, n.value); + put(bytes, 36, @bitCast(n.layer orelse 0)); + const values = [_]f32{ node.frame.x, node.frame.y, node.frame.width, node.frame.height, n.transform.a, n.transform.b, n.transform.c, n.transform.d, n.transform.tx, n.transform.ty }; + for (values, 0..) |value, v| float(bytes, 40 + v * 4, value); + } + // Path depth is 32, so the complete capture/target/bubble path is 63. + var output: [24 + 63 * 8]u8 = undefined; + const written = policy(request, &output); + if (written < 24 or written > output.len or output[0] != 1 or output[1] != op or output[2] > 2 or (if (op == 3) output[3] > 1 else output[3] != 0) or word(&output, 16) != 0 or word(&output, 20) != 0) @panic("invalid compiled routing result"); + const len = word(&output, 12); + if (len > 63 or len > capacity or written != 24 + @as(usize, len) * 8 or (op != 4 and (op < 6 or op > 9) and len != 0) or ((op < 6 or op > 9) and output[2] != 0)) @panic("invalid compiled routing path"); + var result = Result{ .status = output[2], .keep_hit = output[3] == 1, .target = readIndex(word(&output, 4), count), .press = readIndex(word(&output, 8), count), .len = len }; + for (0..len) |i| { + result.indices[i] = readIndex(word(&output, 24 + i * 8), count) orelse @panic("missing compiled route entry"); + result.phases[i] = switch (word(&output, 28 + i * 8)) { + 0 => .capture, + 1 => .target, + 2 => .bubble, + else => @panic("invalid compiled route phase"), + }; + } + return result; +} diff --git a/src/primitives/canvas/widget_runtime.zig b/src/primitives/canvas/widget_runtime.zig index e4fca602e..13128f94f 100644 --- a/src/primitives/canvas/widget_runtime.zig +++ b/src/primitives/canvas/widget_runtime.zig @@ -69,6 +69,9 @@ pub const WidgetLayoutTree = struct { /// Explicit rule owner; independent of semantic and layout policies. audit_policy: ?@import("widget_audit_policy.zig").Policy = null, + /// Explicit input owner, preserved by retained runtime views. + routing_policy: ?@import("widget_routing_policy.zig").Policy = null, + pub fn nodeCount(self: WidgetLayoutTree) usize { return self.nodes.len; } @@ -221,7 +224,7 @@ pub fn layoutWidgetTreeWithTokens(widget: Widget, bounds: geometry.RectF, tokens const root_bounds = bounds.normalized(); var len: usize = 0; _ = try widget_layout.layoutWidgetDepth(widget, root_bounds, null, 0, output, &len, tokens); - return .{ .nodes = output[0..len], .root_bounds = root_bounds, .semantic_policy = tokens.intrinsic_layout_policy, .audit_policy = tokens.widget_audit_policy }; + return .{ .nodes = output[0..len], .root_bounds = root_bounds, .semantic_policy = tokens.intrinsic_layout_policy, .audit_policy = tokens.widget_audit_policy, .routing_policy = tokens.widget_routing_policy }; } pub fn intrinsicWidgetSize(widget: Widget, tokens: DesignTokens) geometry.SizeF { diff --git a/src/runtime/ts_ui_app.zig b/src/runtime/ts_ui_app.zig index 2270cf69e..55da7f1c7 100644 --- a/src/runtime/ts_ui_app.zig +++ b/src/runtime/ts_ui_app.zig @@ -464,6 +464,8 @@ pub fn TsUiAppWithFeatures(comptime core: type, comptime features: ui_app.UiAppF stamped.widget_motion_policy = core.nativeWindowPolicy; if (options.widget_audit_policy != null) @panic("TsUiApp owns widget_audit_policy - remove custom audit wiring"); stamped.widget_audit_policy = core.nativeWindowPolicy; + if (options.widget_routing_policy != null) @panic("TsUiApp owns widget_routing_policy - remove custom routing wiring"); + stamped.widget_routing_policy = core.nativeWindowPolicy; } if (comptime @hasDecl(core, "nativeEffectPolicy")) { if (options.app_dispatch_policy != null) @panic("TsUiApp owns app_dispatch_policy - remove custom dispatch wiring"); diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index 485fbfa15..90fae3896 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -527,6 +527,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe intrinsic_layout_policy: ?*const fn ([]const u8, []u8) usize = null, widget_motion_policy: ?*const fn ([]const u8, []u8) usize = null, widget_audit_policy: ?*const fn ([]const u8, []u8) usize = null, + widget_routing_policy: ?*const fn ([]const u8, []u8) usize = null, /// Which built-in theme pack the stock tokens resolve when /// the app claims neither `tokens` nor `tokens_fn`: the /// pack composes with the live system appearance (scheme, @@ -2018,6 +2019,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe tokens.intrinsic_layout_policy = self.options.intrinsic_layout_policy; tokens.widget_motion_policy = self.options.widget_motion_policy; tokens.widget_audit_policy = self.options.widget_audit_policy; + tokens.widget_routing_policy = self.options.widget_routing_policy; tokens.code_content_policy = self.options.code_content_policy; return tokens; } @@ -2070,6 +2072,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe tokens.intrinsic_layout_policy = self.options.intrinsic_layout_policy; tokens.widget_motion_policy = self.options.widget_motion_policy; tokens.widget_audit_policy = self.options.widget_audit_policy; + tokens.widget_routing_policy = self.options.widget_routing_policy; tokens.code_content_policy = self.options.code_content_policy; return tokens; } diff --git a/src/runtime/view_widget_tree.zig b/src/runtime/view_widget_tree.zig index c41ce37c7..f470df215 100644 --- a/src/runtime/view_widget_tree.zig +++ b/src/runtime/view_widget_tree.zig @@ -101,6 +101,7 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type { .root_bounds = self.widget_layout_root_bounds, .semantic_policy = self.widget_tokens.intrinsic_layout_policy, .audit_policy = self.widget_tokens.widget_audit_policy, + .routing_policy = self.widget_tokens.widget_routing_policy, }; } diff --git a/tests/ts-core/persist_e2e_tests.zig b/tests/ts-core/persist_e2e_tests.zig index 69add0a7b..4de3c0690 100644 --- a/tests/ts-core/persist_e2e_tests.zig +++ b/tests/ts-core/persist_e2e_tests.zig @@ -14,6 +14,7 @@ test { _ = @import("markdown_content_e2e_tests.zig"); _ = @import("widget_motion_e2e_tests.zig"); _ = @import("widget_audits_e2e_tests.zig"); + _ = @import("widget_routing_e2e_tests.zig"); } const Adapter = native_sdk.TsUiApp(core); diff --git a/tests/ts-core/typography_e2e_tests.zig b/tests/ts-core/typography_e2e_tests.zig index ac88f40e0..8a6684c33 100644 --- a/tests/ts-core/typography_e2e_tests.zig +++ b/tests/ts-core/typography_e2e_tests.zig @@ -33,6 +33,7 @@ test "compiled model themes copy the complete register across frame and commit o reference.intrinsic_layout_policy = snapshot.intrinsic_layout_policy; reference.widget_motion_policy = core.nativeWindowPolicy; reference.widget_audit_policy = core.nativeWindowPolicy; + reference.widget_routing_policy = core.nativeWindowPolicy; reference.code_content_policy = core.nativeWindowPolicy; try std.testing.expectEqualDeep(reference, snapshot); const large = app.model.large; @@ -85,6 +86,7 @@ fn restamp(reference: *canvas.DesignTokens, actual: canvas.DesignTokens) void { reference.intrinsic_layout_policy = actual.intrinsic_layout_policy; reference.widget_motion_policy = core.nativeWindowPolicy; reference.widget_audit_policy = core.nativeWindowPolicy; + reference.widget_routing_policy = core.nativeWindowPolicy; reference.code_content_policy = core.nativeWindowPolicy; } test "compiled themes retain forced scheme, accessibility and complete-register precedence" { diff --git a/tests/ts-core/widget_routing_e2e_tests.zig b/tests/ts-core/widget_routing_e2e_tests.zig new file mode 100644 index 000000000..a4bb0ea88 --- /dev/null +++ b/tests/ts-core/widget_routing_e2e_tests.zig @@ -0,0 +1,237 @@ +//! Independent native routing compared with the production compiled owner. +const std = @import("std"); +const sdk = @import("native_sdk"); +const core = @import("ts_persist_core"); +const canvas = sdk.canvas; +const routing = @import("native_sdk").canvas.widget_routing_policy; +const exact = @import("component_construction_e2e_tests.zig").exact; +const tree = @import("native_sdk").canvas.WidgetLayoutTree; +fn node(widget: canvas.Widget, frame: sdk.geometry.RectF, parent: ?usize, depth: usize) canvas.WidgetLayoutNode { + return .{ .widget = widget, .frame = frame, .parent_index = parent, .depth = depth }; +} +fn owned(reference: tree) tree { + var value = reference; + value.routing_policy = core.nativeWindowPolicy; + return value; +} +fn routeEqual(expected: anytype, actual: @TypeOf(expected)) !void { + if (expected) |value| { + try exact(value, try actual); + } else |err| try std.testing.expectError(err, actual); +} +fn compare(reference: tree, tokens: canvas.DesignTokens, point: sdk.geometry.PointF) !void { + const compiled = owned(reference); + errdefer std.debug.print("routing parity point ({d},{d}) nodes {d}\n", .{ point.x, point.y, reference.nodes.len }); + const a = reference.hitTestWithTokens(point, tokens); + const b = compiled.hitTestWithTokens(point, tokens); + core.rt.frameReset(); + try exact(a, b); + try exact(reference.hitTestHoverWithTokens(point, tokens), compiled.hitTestHoverWithTokens(point, tokens)); + try exact(reference.hoverTargetForHit(a), compiled.hoverTargetForHit(b)); + var hover_a: [32]u64 = undefined; + var hover_b: [32]u64 = undefined; + try exact(reference.hoverMsgChainForHit(a, &hover_a), compiled.hoverMsgChainForHit(b, &hover_b)); + for (reference.nodes, 0..) |n, i| { + try exact(reference.focusTargetById(n.widget.id), compiled.focusTargetById(n.widget.id)); + try exact(reference.focusTargetAtIndex(i), compiled.focusTargetAtIndex(i)); + try exact(reference.logicalFocusTargetAtIndex(i), compiled.logicalFocusTargetAtIndex(i)); + for (std.enums.values(canvas.WidgetFocusDirection)) |direction| try exact(reference.focusTarget(n.widget.id, direction), compiled.focusTarget(n.widget.id, direction)); + } + for (std.enums.values(canvas.WidgetFocusDirection)) |direction| try exact(reference.focusTarget(null, direction), compiled.focusTarget(null, direction)); + var entries_a: [63]canvas.WidgetEventRouteEntry = undefined; + var entries_b: [63]canvas.WidgetEventRouteEntry = undefined; + for (std.enums.values(canvas.WidgetPointerPhase)) |phase| try routeEqual(reference.routePointerEventWithTokens(.{ .phase = phase, .point = point }, tokens, &entries_a), compiled.routePointerEventWithTokens(.{ .phase = phase, .point = point }, tokens, &entries_b)); + try routeEqual(reference.routeFileDropEvent(.{ .point = point, .paths = &.{"raw\xff\x00path"} }, &entries_a), compiled.routeFileDropEvent(.{ .point = point, .paths = &.{"raw\xff\x00path"} }, &entries_b)); + for (reference.nodes) |n| { + try routeEqual(reference.routeDragEvent(.{ .source_id = n.widget.id, .point = point }, &entries_a), compiled.routeDragEvent(.{ .source_id = n.widget.id, .point = point }, &entries_b)); + try routeEqual(reference.routeKeyboardEvent(.{ .phase = .key_down, .focused_id = n.widget.id }, &entries_a), compiled.routeKeyboardEvent(.{ .phase = .key_down, .focused_id = n.widget.id }, &entries_b)); + } + core.rt.frameReset(); +} +test "compiled widget routing preserves every kind role and authored interaction gate" { + _ = core.initialModel(); + const frame = sdk.geometry.RectF.init(0, 0, 40, 30); + for (std.enums.values(canvas.WidgetKind)) |kind| for (0..32) |flags| { + const widget = canvas.Widget{ .kind = kind, .id = if (flags & 1 != 0) 0 else 0xfedcba9876543210, .state = .{ .disabled = flags & 2 != 0 }, .semantics = .{ .hidden = flags & 4 != 0, .focusable = flags & 8 != 0, .role = if (flags & 16 != 0) .treeitem else .none } }; + var nodes = [_]canvas.WidgetLayoutNode{node(widget, frame, null, 0)}; + try compare(.{ .nodes = &nodes }, .{}, .init(5, 5)); + }; + for (std.enums.values(canvas.WidgetRole)) |role| for (0..16) |flags| { + var widget = canvas.Widget{ .kind = .row, .id = 1, .semantics = .{ .role = role, .actions = .{ .press = flags & 1 != 0, .toggle = flags & 2 != 0, .drag = flags & 4 != 0, .drop_files = flags & 8 != 0 } } }; + widget.hover_msgs = flags & 1 != 0; + widget.window_drag = flags & 2 != 0; + var nodes = [_]canvas.WidgetLayoutNode{node(widget, frame, null, 0)}; + try compare(.{ .nodes = &nodes }, .{}, .init(5, 5)); + }; +} +test "compiled widget routing preserves hoisted modal ordering ancestor clips and disclosure settlement" { + _ = core.initialModel(); + var nodes = [_]canvas.WidgetLayoutNode{ + node(.{ .kind = .scroll_view, .id = 1 }, .init(0, 0, 100, 100), null, 0), + node(.{ .kind = .accordion, .id = 2 }, .init(0, 0, 100, 100), 0, 1), + node(.{ .kind = .button, .id = 3 }, .init(110, 110, 40, 30), 1, 2), + node(.{ .kind = .dialog, .id = 4 }, .init(110, 110, 80, 80), 1, 2), + node(.{ .kind = .button, .id = 5, .layout = .{ .anchor = .{} } }, .init(110, 110, 40, 30), 3, 3), + node(.{ .kind = .button, .id = 6, .layout = .{ .anchor = .{} } }, .init(110, 110, 40, 30), null, 0), + }; + for (0..64) |flags| { + nodes[0].widget.semantics.hidden = flags & 1 != 0; + nodes[1].widget.state.selected = flags & 2 != 0; + nodes[1].frame.height = if (flags & 4 != 0) 150 else 139.5; + nodes[4].widget.layer = if (flags & 8 != 0) -1 else null; + nodes[5].widget.layer = if (flags & 16 != 0) 21 else null; + nodes[4].widget.state.disabled = flags & 32 != 0; + try compare(.{ .nodes = &nodes }, .{ .layer = .{ .base = -2, .overlay = 10, .modal = 20, .floating = 30 } }, .init(115, 115)); + } +} +test "compiled widget routing preserves nested inverse transforms negative extents and float edges" { + _ = core.initialModel(); + const transforms = [_]canvas.Affine{ .{}, .translate(40, 20), .scale(2, 0.5), .{ .a = 1, .b = 0.5, .c = 0.25, .d = 1, .tx = -13.75, .ty = 5.25 }, .{ .a = 0.000001 }, .{ .a = 0 }, .{ .tx = -0.0 } }; + var nodes = [_]canvas.WidgetLayoutNode{ node(.{ .kind = .row, .id = 1 }, .init(30, 30, -30, -30), null, 0), node(.{ .kind = .button, .id = 2 }, .init(0, 0, 30, 30), 0, 1) }; + for (transforms) |parent| for (transforms) |child| { + nodes[0].widget.transform = parent; + nodes[1].widget.transform = child; + for ([_]f32{ -0.0, 0, 0.000001, 15, 29.999998, 30, 60 }) |x| try compare(.{ .nodes = &nodes }, .{}, .init(x, 15)); + }; +} +test "compiled widget routing preserves capture phases eligibility and exact wide identities" { + _ = core.initialModel(); + var nodes = [_]canvas.WidgetLayoutNode{ node(.{ .kind = .scroll_view, .id = 1 }, .init(0, 0, 100, 100), null, 0), node(.{ .kind = .button, .id = std.math.maxInt(u64) }, .init(10, 10, 30, 30), 0, 1) }; + var a: [63]canvas.WidgetEventRouteEntry = undefined; + var b: [63]canvas.WidgetEventRouteEntry = undefined; + for (0..16) |flags| { + nodes[0].widget.semantics.hidden = flags & 1 != 0; + nodes[1].widget.state.disabled = flags & 2 != 0; + nodes[1].widget.semantics.hidden = flags & 4 != 0; + nodes[1].frame.x = if (flags & 8 != 0) 200 else 10; + const reference = tree{ .nodes = &nodes }; + const compiled = owned(reference); + for (std.enums.values(canvas.WidgetPointerPhase)) |phase| for ([_]?u64{ null, 0, 1, 123, std.math.maxInt(u64) }) |id| { + for ([_]sdk.geometry.PointF{ .init(15, 15), .init(1000, 1000) }) |point| { + const event = canvas.WidgetPointerEvent{ .phase = phase, .captured_id = id, .point = point }; + try routeEqual(reference.routePointerEvent(event, &a), compiled.routePointerEvent(event, &b)); + } + }; + } + core.rt.frameReset(); +} +test "compiled widget routing preserves complete paths and caller prefixes at depth and capacity limits" { + _ = core.initialModel(); + var nodes: [40]canvas.WidgetLayoutNode = undefined; + for (&nodes, 0..) |*n, i| n.* = node(.{ .kind = .button, .id = i + 1, .hover_msgs = true }, .init(0, 0, 30, 30), if (i > 0) i - 1 else null, i); + var a = [_]canvas.WidgetEventRouteEntry{.{ .phase = .bubble, .node_index = 999, .id = 999, .kind = .chart, .bounds = .init(-0.0, 1, 2, 3) }} ** 64; + var b = a; + for ([_]usize{ 1, 2, 31, 32, 33, 40 }) |depth| for ([_]usize{ 0, 1, 2, 3, 31, 62, 63, 64 }) |capacity| { + const reference = tree{ .nodes = nodes[0..depth] }; + const compiled = owned(reference); + const before = a; + const event = canvas.WidgetPointerEvent{ .phase = .down, .point = .init(5, 5) }; + try routeEqual(reference.routePointerEvent(event, a[0..capacity]), compiled.routePointerEvent(event, b[0..capacity])); + core.rt.frameReset(); + try exact(a, b); + a = before; + b = before; + var left: [40]u64 = undefined; + var right: [40]u64 = undefined; + const hit = reference.hitTest(.init(5, 5)); + try exact(reference.hoverMsgChainForHit(hit, left[0..@min(capacity, 40)]), compiled.hoverMsgChainForHit(hit, right[0..@min(capacity, 40)])); + }; +} +test "compiled widget routing preserves spatial scoring ties logical focus and scroll observation" { + _ = core.initialModel(); + var nodes = [_]canvas.WidgetLayoutNode{ + node(.{ .kind = .scroll_view, .id = 1, .scroll_axes = .both }, .init(0, 0, 100, 100), null, 0), + node(.{ .kind = .button, .id = 2 }, .init(10, 10, 20, 20), 0, 1), + node(.{ .kind = .button, .id = 3 }, .init(50, 10, 20, 20), 0, 1), + node(.{ .kind = .button, .id = 4 }, .init(50, 10, 20, 20), 0, 1), + node(.{ .kind = .row, .id = 5, .semantics = .{ .role = .treeitem } }, .init(10, 50, 20, 20), 0, 1), + node(.{ .kind = .button, .id = 6 }, .init(10, 150, 20, 20), 0, 1), + }; + for ([_]f32{ -0.0, 0, 9.999999, 10, 10.000001, 30, 100, 1000 }) |offset| { + nodes[2].frame.y = offset; + try compare(.{ .nodes = &nodes }, .{}, .init(15, 15)); + } + // Existing portable semantic observations compose with routing without + // collecting or borrowing the routing request's storage. + const reference = tree{ .nodes = &nodes }; + var compiled = owned(reference); + compiled.semantic_policy = core.nativeWindowPolicy; + for (nodes, 0..) |n, i| { + try exact(reference.focusTargetById(n.widget.id), compiled.focusTargetById(n.widget.id)); + try exact(reference.logicalFocusTargetAtIndex(i), compiled.logicalFocusTargetAtIndex(i)); + } + core.rt.frameReset(); +} +test "compiled widget routing preserves press hover table-row and window-drag attribution" { + _ = core.initialModel(); + var nodes = [_]canvas.WidgetLayoutNode{ + node(.{ .kind = .row, .id = 1, .window_drag = true, .hover_msgs = true }, .init(0, 0, 100, 100), null, 0), + node(.{ .kind = .data_row, .id = 2, .hover_msgs = true }, .init(0, 0, 100, 30), 0, 1), + node(.{ .kind = .data_cell, .id = 3 }, .init(0, 0, 30, 30), 1, 2), + node(.{ .kind = .text, .id = 4 }, .init(0, 0, 20, 20), 2, 3), + }; + for (0..32) |flags| { + nodes[1].widget.semantics.actions.press = flags & 1 != 0; + nodes[2].widget.semantics.actions.press = flags & 2 != 0; + nodes[3].widget.semantics.role = if (flags & 4 != 0) .link else .none; + nodes[2].widget.state.disabled = flags & 8 != 0; + nodes[0].widget.semantics.actions.press = flags & 16 != 0; + try compare(.{ .nodes = &nodes }, .{}, .init(5, 5)); + const reference = tree{ .nodes = &nodes }; + const result = routing.query(owned(reference), core.nativeWindowPolicy, 5, 0, 3, null, .{}, .{}, 0, {}); + const expected = @import("native_sdk").canvas.widgetWindowDragTargetIndexFromNode(reference, 3); + try exact(expected, result.target); + } +} +const Adapter = sdk.TsUiApp(core); +test "compiled widget routing preserves complete caller supplied hover hits" { + _ = core.initialModel(); + var nodes = [_]canvas.WidgetLayoutNode{ node(.{ .kind = .row, .id = 1 }, .init(0, 0, 40, 40), null, 0), node(.{ .kind = .text, .id = 2 }, .init(0, 0, 30, 30), 0, 1) }; + for (0..32) |flags| { + nodes[0].widget.semantics.actions.press = flags & 1 != 0; + nodes[0].widget.kind = if (flags & 2 != 0) .data_row else .row; + const reference = tree{ .nodes = &nodes }; + const compiled = owned(reference); + var hit = reference.hitTest(.init(5, 5)).?; + hit.bounds = .init(-0.0, 101.25, -8, 16); + hit.id = std.math.maxInt(u64); + hit.depth = 17; + hit.role = if (flags & 4 != 0) .link else .none; + hit.state.disabled = flags & 8 != 0; + hit.kind = if (flags & 16 != 0) .data_cell else .text; + try exact(reference.hoverTargetForHit(hit), compiled.hoverTargetForHit(hit)); + core.rt.frameReset(); + } +} +fn view(ui: *Adapter.Ui, _: *const core.Model) Adapter.Ui.Node { + return ui.button(.{ .on_press = .increment_and_persist }, "Route"); +} +fn routingTokens(_: *const core.Model) canvas.DesignTokens { + return .{ .density = .spacious }; +} +test "compiled widget routing retains explicit ownership through tokens solved and retained views" { + const views = [_]sdk.ShellView{.{ .label = "canvas", .kind = .gpu_surface, .fill = true }}; + const windows = [_]sdk.ShellWindow{.{ .label = "main", .title = "Routing", .width = 400, .height = 300, .views = &views }}; + const scene = sdk.ShellConfig{ .windows = &windows }; + for ([_]bool{ false, true }) |dynamic| { + const h = try sdk.TestHarness().create(std.testing.allocator, .{}); + defer h.destroy(std.testing.allocator); + h.null_platform.gpu_surfaces = true; + var state = Adapter.init(std.testing.allocator, .{}, .{ .name = "routing-ownership", .scene = scene, .canvas_label = "canvas", .view = view, .tokens = .{ .density = .compact }, .tokens_fn = if (dynamic) routingTokens else null }); + defer state.deinit(); + const app = state.app(); + try h.start(app); + try h.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ .label = "canvas", .size = .init(400, 300), .timestamp_ns = 1000000 } }); + for (0..2) |turn| { + const v = &h.runtime.views[0]; + try std.testing.expectEqual(@as(?routing.Policy, core.nativeWindowPolicy), v.widget_tokens.widget_routing_policy); + const layout = v.widgetLayoutTree(); + try std.testing.expectEqual(@as(?routing.Policy, core.nativeWindowPolicy), layout.routing_policy); + var reference = layout; + reference.routing_policy = null; + const point = layout.nodes[0].frame.center(); + try exact(reference.hitTest(point), layout.hitTest(point)); + if (turn == 0) try state.dispatch(&h.runtime, state.canvas_window_id, .increment_and_persist); + } + } +} From 0c1855f6ee0bc169a53a838cc77e4340053d0481 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 6 Oct 2026 22:24:35 -0500 Subject: [PATCH 2/4] Avoid redundant focus observations - Request semantic scroll facts only when focus eligibility needs them. - Preserve complete routing results and verify bounded continuation. --- packages/core/src/widget_routing.ts | 44 +++++++++++++------ packages/core/test/widget_routing.test.ts | 21 +++++++-- .../canvas/widget_routing_policy.zig | 28 +++++++++--- tests/ts-core/widget_routing_e2e_tests.zig | 26 +++++++++++ 4 files changed, 95 insertions(+), 24 deletions(-) diff --git a/packages/core/src/widget_routing.ts b/packages/core/src/widget_routing.ts index 858fe532b..1f19f44f8 100644 --- a/packages/core/src/widget_routing.ts +++ b/packages/core/src/widget_routing.ts @@ -8,6 +8,7 @@ interface NscRouteNode { frame: NscSurfaceRect; transform: number[]; } interface NscRouteEntry { index: number; phase: number } +interface NscRouteObservation { index: number } function nscvRouteModal(n: NscRouteNode): boolean { return n.kind >= 19 && n.kind <= 21; } function nscvRouteEscapes(n: NscRouteNode): boolean { return (n.flags & 8) !== 0 || nscvRouteModal(n); } function nscvRouteClips(n: NscRouteNode): boolean { return n.kind === 6 || (n.flags & 16) !== 0; } @@ -138,23 +139,32 @@ function nscvRouteHitTree(nodes: NscRouteNode[], x: number, y: number, layers: n function nscvRoutePress(nodes: NscRouteNode[], index: number): number { let i = index; while (i < nodes.length) { if (nscvRouteClaims(nodes[i]!)) return i; i = nodes[i]!.parent; } return 4294967295; } -function nscvRouteFocus(nodes: NscRouteNode[], index: number, logical: boolean): boolean { +function nscvRouteFocus(nodes: NscRouteNode[], index: number, logical: boolean, observation: NscRouteObservation): boolean { const n = nodes[index]!; - return nscvRouteLive(n) && !nscvRouteHidden(nodes, index) && !nscvRouteConcealed(nodes, index) && - ((n.flags & 128) !== 0 || (n.actions & 1) !== 0 || nscvRouteDefaultFocus(n) || (n.flags & 256) !== 0) && (logical || nscvRouteVisible(nodes, index, false, 0, 0)); + if (!nscvRouteLive(n) || nscvRouteHidden(nodes, index) || nscvRouteConcealed(nodes, index)) return false; + if ((n.flags & 128) === 0 && (n.actions & 1) === 0 && !nscvRouteDefaultFocus(n)) { + // Ask for a semantic observation only at the same eligibility gate as + // the reference. Native copies that fact, then resumes this query. + if ((n.flags & 2048) === 0) { + if (observation.index === 4294967295) observation.index = index; + return false; + } + if ((n.flags & 256) === 0) return false; + } + return logical || nscvRouteVisible(nodes, index, false, 0, 0); } function nscvRouteGap(a: number, sizeA: number, b: number, sizeB: number): number { const f = Math.fround, endA = f(a + sizeA), endB = f(b + sizeB); if (nscvSurfaceMin(endA, endB) > nscvSurfaceMax(a, b)) return 0; return b >= endA ? f(b - endA) : f(a - endB); } -function nscvRouteSpatial(nodes: NscRouteNode[], current: number, direction: number): number { +function nscvRouteSpatial(nodes: NscRouteNode[], current: number, direction: number, observation: NscRouteObservation): number { const missing = 4294967295, f = Math.fround; - if (!nscvRouteFocus(nodes, current, false)) return missing; + if (!nscvRouteFocus(nodes, current, false, observation)) return missing; const a = nodes[current]!.frame, ax = f(a.x + f(a.width / 2)), ay = f(a.y + f(a.height / 2)); let best = missing, bestScore = Infinity; for (let i = 0; i < nodes.length; i++) { - if (i === current || !nscvRouteFocus(nodes, i, false)) continue; + if (i === current || !nscvRouteFocus(nodes, i, false, observation)) continue; const b = nodes[i]!.frame; if (!(direction === 2 ? f(b.x + b.width) <= ax : direction === 3 ? b.x >= ax : direction === 4 ? f(b.y + b.height) <= ay : b.y >= ay)) continue; const bx = f(b.x + f(b.width / 2)), by = f(b.y + f(b.height / 2)), dx = Math.abs(f(bx - ax)), dy = Math.abs(f(by - ay)); @@ -177,7 +187,7 @@ function nscvWidgetRouting(request: Uint8Array): Uint8Array { const at = 64 + i * 96, kind = wire.getUint32(at, true), role = wire.getUint32(at + 4, true), parent = wire.getUint32(at + 8, true), flags = wire.getUint32(at + 24, true), actions = wire.getUint32(at + 28, true); // Solved trees are preorder. Requiring an earlier parent also excludes // cycles before any recursive or ancestor walk can begin. - if (kind > 62 || role > 26 || parent !== missing && parent >= i || flags > 2047 || actions > 2047) throw new Error("invalid widget routing node facts"); + if (kind > 62 || role > 26 || parent !== missing && parent >= i || flags > 4095 || actions > 2047) throw new Error("invalid widget routing node facts"); for (let r = 80; r < 96; r += 4) if (wire.getUint32(at + r, true) !== 0) throw new Error("invalid widget routing node reserved bytes"); const transform: number[] = []; for (let t = 0; t < 6; t++) transform.push(wire.getFloat32(at + 56 + t * 4, true)); nodes.push({ kind, role, parent, depth: wire.getUint32(at + 12, true), lo: wire.getUint32(at + 16, true), hi: wire.getUint32(at + 20, true), flags, actions, @@ -187,6 +197,7 @@ function nscvWidgetRouting(request: Uint8Array): Uint8Array { const lo = wire.getUint32(16, true), hi = wire.getUint32(20, true), x = wire.getFloat32(24, true), y = wire.getFloat32(28, true); const lookup = (): number => { if (lo === 0 && hi === 0) return missing; for (let i = 0; i < count; i++) if (nodes[i]!.lo === lo && nodes[i]!.hi === hi) return i; return missing; }; let target = missing, press = missing, status = 0, keepHit = false; + const observation: NscRouteObservation = { index: missing }; const entries: NscRouteEntry[] = []; if (op <= 1) target = nscvRouteHitTree(nodes, x, y, layers, op === 1); else if (op === 2) target = nscvRoutePress(nodes, subject); @@ -210,7 +221,7 @@ function nscvWidgetRouting(request: Uint8Array): Uint8Array { const i = lookup(); if (i < count && nscvRouteHit(nodes[i]!, false) && !nscvRouteHidden(nodes, i) && !nscvRouteConcealed(nodes, i) && nscvRouteVisible(nodes, i, false, 0, 0)) target = i; } else target = nscvRouteHitTree(nodes, x, y, layers, false); press = nscvRoutePress(nodes, target); - } else if (op === 7 || op === 11) { const i = lookup(); if (i < count && nscvRouteFocus(nodes, i, false)) target = i; } + } else if (op === 7 || op === 11) { const i = lookup(); if (i < count && nscvRouteFocus(nodes, i, false, observation)) target = i; } else if (op === 8 && param === 1) { for (let i = count - 1; i >= 0; i--) { const n = nodes[i]!; if (nscvRouteLive(n) && (n.flags & 1) === 0 && (n.actions & 512) !== 0 && !nscvRouteHidden(nodes, i) && !nscvRouteConcealed(nodes, i) && nscvRouteContains(n.frame, x, y) && nscvRouteVisible(nodes, i, true, x, y)) { target = i; break; } } } else if (op === 9) { @@ -219,15 +230,20 @@ function nscvWidgetRouting(request: Uint8Array): Uint8Array { } } else if (op === 10) { const current = lookup(); - if (param >= 2) { if (current < count) target = nscvRouteSpatial(nodes, current, param); } + if (param >= 2) { if (current < count) target = nscvRouteSpatial(nodes, current, param, observation); } else if (param === 0) { - for (let i = current === missing ? 0 : current + 1; i < count; i++) if (nscvRouteFocus(nodes, i, false)) { target = i; break; } - if (target === missing) for (let i = 0; i < (current === missing ? count : current); i++) if (nscvRouteFocus(nodes, i, false)) { target = i; break; } + for (let i = current === missing ? 0 : current + 1; i < count; i++) if (nscvRouteFocus(nodes, i, false, observation)) { target = i; break; } + if (target === missing) for (let i = 0; i < (current === missing ? count : current); i++) if (nscvRouteFocus(nodes, i, false, observation)) { target = i; break; } } else { - for (let i = (current === missing ? count : current) - 1; i >= 0; i--) if (nscvRouteFocus(nodes, i, false)) { target = i; break; } - if (target === missing) for (let i = count - 1; i >= (current === missing ? 0 : current + 1); i--) if (nscvRouteFocus(nodes, i, false)) { target = i; break; } + for (let i = (current === missing ? count : current) - 1; i >= 0; i--) if (nscvRouteFocus(nodes, i, false, observation)) { target = i; break; } + if (target === missing) for (let i = count - 1; i >= (current === missing ? 0 : current + 1); i--) if (nscvRouteFocus(nodes, i, false, observation)) { target = i; break; } } - } else if ((op === 12 || op === 13) && subject < count && nscvRouteFocus(nodes, subject, op === 13)) target = subject; + } else if ((op === 12 || op === 13) && subject < count && nscvRouteFocus(nodes, subject, op === 13, observation)) target = subject; + if (observation.index !== missing) { + const pending = new Uint8Array(24), out = new DataView(pending.buffer); + pending.set([1, op, 3, 0]); out.setUint32(4, observation.index, true); out.setUint32(8, missing, true); + return pending; + } if (op >= 6 && op <= 9 && target < count) { const path: number[] = []; let i = target; while (i < count) { if (path.length >= 32) { status = 1; break; } path.push(i); i = nodes[i]!.parent; } diff --git a/packages/core/test/widget_routing.test.ts b/packages/core/test/widget_routing.test.ts index fb62b1cb0..25adeab40 100644 --- a/packages/core/test/widget_routing.test.ts +++ b/packages/core/test/widget_routing.test.ts @@ -6,13 +6,13 @@ const source = ["runtime_policy.ts", "widget_routing.ts"].map(p => readFileSync( const { nscvWidgetRouting: route } = await import(`data:text/javascript;base64,${Buffer.from(stripTypeScriptTypes(source) + "\nexport { nscvWidgetRouting };").toString("base64")}`); const missing = 0xffffffff; const wire = (b: Uint8Array) => new DataView(b.buffer, b.byteOffset, b.byteLength); -interface Node { kind?: number; role?: number; parent?: number; depth?: number; id?: bigint; flags?: number; actions?: number; value?: number; layer?: number; frame?: number[]; transform?: number[] } +interface Node { kind?: number; role?: number; parent?: number; depth?: number; id?: bigint; flags?: number; observed?: boolean; actions?: number; value?: number; layer?: number; frame?: number[]; transform?: number[] } function packet(op: number, nodes: Node[], options: { param?: number; subject?: number; id?: bigint; point?: number[]; capacity?: number; layers?: number[] } = {}) { const b = new Uint8Array(64 + nodes.length * 96), w = wire(b); b.set([24, 1, op, options.param ?? 0]); w.setUint32(4, nodes.length, true); w.setUint32(8, options.subject ?? missing, true); w.setUint32(12, options.capacity ?? 63, true); w.setUint32(48, Number(options.id !== undefined), true); w.setBigUint64(16, options.id ?? 0n, true); (options.point ?? [5, 5]).forEach((v, i) => w.setFloat32(24 + i * 4, v, true)); (options.layers ?? [0, 10, 20, 30]).forEach((v, i) => w.setInt32(32 + i * 4, v, true)); nodes.forEach((n, i) => { const p = 64 + i * 96; - for (const [at, v] of [[0, n.kind ?? 31], [4, n.role ?? 0], [8, n.parent ?? missing], [12, n.depth ?? i], [24, n.flags ?? 0], [28, n.actions ?? 0]]) w.setUint32(p + at!, v!, true); + for (const [at, v] of [[0, n.kind ?? 31], [4, n.role ?? 0], [8, n.parent ?? missing], [12, n.depth ?? i], [24, (n.flags ?? 0) | (n.observed === false ? 0 : 2048)], [28, n.actions ?? 0]]) w.setUint32(p + at!, v!, true); w.setBigUint64(p + 16, n.id ?? BigInt(i + 1), true); w.setFloat32(p + 32, n.value ?? 0, true); w.setInt32(p + 36, n.layer ?? 0, true); (n.frame ?? [0, 0, 30, 30]).forEach((v, t) => w.setFloat32(p + 40 + t * 4, v, true)); (n.transform ?? [1, 0, 0, 1, 0, 0]).forEach((v, t) => w.setFloat32(p + 56 + t * 4, v, true)); }); return b; @@ -76,6 +76,21 @@ test("file-drop reverses mounted order while drag falls through to its eligible assert.equal(result(route(packet(8, nodes))).target, missing); nodes[3]!.flags = 2; assert.equal(result(route(packet(8, nodes, { param: 1 }))).target, 2); }); +test("focus asks only for necessary semantic facts and resumes in reference order", () => { + const nodes: Node[] = [{ kind: 1, observed: false }, { kind: 1, observed: false }, { observed: false }, { kind: 1, observed: false }]; + // Indexed and keyboard focus on a button need no scroll observation, + // including when unrelated containers have no supplied facts. + for (const op of [7, 11]) assert.equal(result(route(packet(op, nodes, { id: 3n }))).status, 0); + for (const op of [12, 13]) assert.equal(result(route(packet(op, nodes, { subject: 2 }))).status, 0); + const request = packet(10, nodes); + assert.deepEqual(result(route(request)), { status: 3, target: 0, press: missing, entries: [] }); + wire(request).setUint32(64 + 24, 2048, true); + assert.deepEqual(result(route(request)), { status: 3, target: 1, press: missing, entries: [] }); + wire(request).setUint32(64 + 96 + 24, 2048 | 256, true); + assert.deepEqual(result(route(request)), { status: 0, target: 1, press: missing, entries: [] }); + // A disabled candidate stands down before it can request a fact. + assert.equal(result(route(packet(13, [{ kind: 1, flags: 2, observed: false }], { subject: 0 }))).status, 0); +}); test("hover keeps a caller's complete link or unclaimed hit and reconstructs claiming ancestors", () => { const nodes: Node[] = [{ kind: 1 }, { kind: 26, parent: 0 }]; let out = route(packet(3, nodes, { subject: 1, param: 1 })); assert.equal(out[3], 1); assert.equal(result(out).target, 1); @@ -91,5 +106,5 @@ test("requests reject truncation, trailing bytes, cycles, future parents and unk const valid = packet(0, [{}]); for (let i = 0; i < valid.length; i++) assert.throws(() => route(valid.subarray(0, i))); const trailing = new Uint8Array(valid.length + 1); trailing.set(valid); assert.throws(() => route(trailing)); - for (const [at, value] of [[0, 0], [1, 2], [2, 14], [3, 1], [48, 2], [52, 1], [64, 63], [68, 27], [72, 0], [88, 2048], [92, 2048], [144, 1]]) { const bad = valid.slice(); if (at! < 4) bad[at!] = value!; else wire(bad).setUint32(at!, value!, true); assert.throws(() => route(bad), `field ${at}`); } + for (const [at, value] of [[0, 0], [1, 2], [2, 14], [3, 1], [48, 2], [52, 1], [64, 63], [68, 27], [72, 0], [88, 4096], [92, 2048], [144, 1]]) { const bad = valid.slice(); if (at! < 4) bad[at!] = value!; else wire(bad).setUint32(at!, value!, true); assert.throws(() => route(bad), `field ${at}`); } }); diff --git a/src/primitives/canvas/widget_routing_policy.zig b/src/primitives/canvas/widget_routing_policy.zig index 66164b17a..a4b54674c 100644 --- a/src/primitives/canvas/widget_routing_policy.zig +++ b/src/primitives/canvas/widget_routing_policy.zig @@ -58,8 +58,8 @@ fn readIndex(value: u32, count: usize) ?usize { if (value >= count) @panic("invalid compiled routing index"); return value; } -/// op is a protocol query, param is its phase/direction. Scroll facts are -/// observed only for focus queries, through the existing semantic owner. +/// op is a protocol query, param is its phase/direction. The portable owner +/// requests scroll observations only when ordinary focus gates need them. pub fn query(layout: anytype, policy: Policy, comptime op: u8, param: u8, subject: ?usize, id: ?u64, point: geometry.PointF, tokens: tokens_model.DesignTokens, capacity: usize, scroll_semantics_fn: anytype) Result { const allocator = std.heap.page_allocator; const count = std.math.cast(u32, layout.nodes.len) orelse @panic("routing node capacity"); @@ -86,14 +86,11 @@ pub fn query(layout: anytype, policy: Policy, comptime op: u8, param: u8, subjec put(bytes, 12, std.math.cast(u32, node.depth) orelse @panic("routing depth capacity")); std.mem.writeInt(u64, bytes[16..24], n.id, .little); // Authored flags only: no native target/visibility/focus verdicts. - var flags: u32 = @intFromBool(n.semantics.hidden) | (@as(u32, @intFromBool(n.state.disabled)) << 1) | + const flags: u32 = @intFromBool(n.semantics.hidden) | (@as(u32, @intFromBool(n.state.disabled)) << 1) | (@as(u32, @intFromBool(n.state.selected)) << 2) | (@as(u32, @intFromBool(n.layout.anchor != null)) << 3) | (@as(u32, @intFromBool(n.layout.clip_content)) << 4) | (@as(u32, @intFromBool(n.hover_msgs)) << 5) | (@as(u32, @intFromBool(n.window_drag)) << 6) | (@as(u32, @intFromBool(n.semantics.focusable)) << 7) | (@as(u32, @intFromBool(n.layer != null)) << 9) | (@as(u32, @intFromBool(n.chart.hover_details)) << 10); - if (comptime op == 7 or op >= 10) { - if (scroll_semantics_fn(layout, i).scrollable) flags |= 256; - } put(bytes, 24, flags); put(bytes, 28, events.semanticActionBits(n.semantics.actions)); float(bytes, 32, n.value); @@ -103,7 +100,24 @@ pub fn query(layout: anytype, policy: Policy, comptime op: u8, param: u8, subjec } // Path depth is 32, so the complete capture/target/bubble path is 63. var output: [24 + 63 * 8]u8 = undefined; - const written = policy(request, &output); + var written: usize = undefined; + var observations: usize = 0; + while (true) { + written = policy(request, &output); + if (written < 24 or output[2] != 3) break; + if (comptime op == 7 or op >= 10) { + const index = word(&output, 4); + if (written != 24 or output[0] != 1 or output[1] != op or output[3] != 0 or index >= count or word(&output, 8) != missing or word(&output, 12) != 0 or word(&output, 16) != 0 or word(&output, 20) != 0 or observations >= count) + @panic("invalid compiled routing observation"); + const at = 64 + @as(usize, index) * 96 + 24; + var flags = word(request, at); + if (flags & 2048 != 0) @panic("repeated compiled routing observation"); + flags |= 2048; + if (scroll_semantics_fn(layout, index).scrollable) flags |= 256; + put(request, at, flags); + observations += 1; + } else @panic("unexpected compiled routing observation"); + } if (written < 24 or written > output.len or output[0] != 1 or output[1] != op or output[2] > 2 or (if (op == 3) output[3] > 1 else output[3] != 0) or word(&output, 16) != 0 or word(&output, 20) != 0) @panic("invalid compiled routing result"); const len = word(&output, 12); if (len > 63 or len > capacity or written != 24 + @as(usize, len) * 8 or (op != 4 and (op < 6 or op > 9) and len != 0) or ((op < 6 or op > 9) and output[2] != 0)) @panic("invalid compiled routing path"); diff --git a/tests/ts-core/widget_routing_e2e_tests.zig b/tests/ts-core/widget_routing_e2e_tests.zig index a4bb0ea88..5ed1cf549 100644 --- a/tests/ts-core/widget_routing_e2e_tests.zig +++ b/tests/ts-core/widget_routing_e2e_tests.zig @@ -14,6 +14,32 @@ fn owned(reference: tree) tree { value.routing_policy = core.nativeWindowPolicy; return value; } +var observation_indices: [128]usize = undefined; +var observation_count: usize = 0; +fn observeScroll(_: tree, index: usize) struct { scrollable: bool } { + observation_indices[observation_count] = index; + observation_count += 1; + return .{ .scrollable = index == 1 }; +} +test "compiled widget routing requests only required scroll observations in focus order" { + _ = core.initialModel(); + defer core.rt.frameReset(); + var nodes: [128]canvas.WidgetLayoutNode = undefined; + for (&nodes, 0..) |*n, i| n.* = node(.{ .kind = if (i == 2) .button else .row, .id = i + 1 }, .init(0, 0, 30, 30), null, 0); + const layout = owned(.{ .nodes = &nodes }); + observation_count = 0; + const direct = routing.query(layout, core.nativeWindowPolicy, 12, 0, 2, null, .{}, .{}, 0, observeScroll); + try std.testing.expectEqual(@as(?usize, 2), direct.target); + try std.testing.expectEqual(@as(usize, 0), observation_count); + const forward = routing.query(layout, core.nativeWindowPolicy, 10, 0, null, null, .{}, .{}, 0, observeScroll); + try std.testing.expectEqual(@as(?usize, 1), forward.target); + try std.testing.expectEqualSlices(usize, &.{ 0, 1 }, observation_indices[0..observation_count]); + observation_count = 0; + nodes[1].widget.state.disabled = true; + const eligible = routing.query(layout, core.nativeWindowPolicy, 10, 0, null, null, .{}, .{}, 0, observeScroll); + try std.testing.expectEqual(@as(?usize, 2), eligible.target); + try std.testing.expectEqualSlices(usize, &.{0}, observation_indices[0..observation_count]); +} fn routeEqual(expected: anytype, actual: @TypeOf(expected)) !void { if (expected) |value| { try exact(value, try actual); From ad45a556f9c106226e48060a7b3e5b25cb1fcf5a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 6 Oct 2026 22:32:20 -0500 Subject: [PATCH 3/4] Disambiguate routing observation storage - Keep internal observation fields distinct from app message payloads. --- packages/core/src/widget_routing.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/widget_routing.ts b/packages/core/src/widget_routing.ts index 1f19f44f8..aedd19434 100644 --- a/packages/core/src/widget_routing.ts +++ b/packages/core/src/widget_routing.ts @@ -8,7 +8,7 @@ interface NscRouteNode { frame: NscSurfaceRect; transform: number[]; } interface NscRouteEntry { index: number; phase: number } -interface NscRouteObservation { index: number } +interface NscRouteObservation { routing_observation_slot: number } function nscvRouteModal(n: NscRouteNode): boolean { return n.kind >= 19 && n.kind <= 21; } function nscvRouteEscapes(n: NscRouteNode): boolean { return (n.flags & 8) !== 0 || nscvRouteModal(n); } function nscvRouteClips(n: NscRouteNode): boolean { return n.kind === 6 || (n.flags & 16) !== 0; } @@ -146,7 +146,7 @@ function nscvRouteFocus(nodes: NscRouteNode[], index: number, logical: boolean, // Ask for a semantic observation only at the same eligibility gate as // the reference. Native copies that fact, then resumes this query. if ((n.flags & 2048) === 0) { - if (observation.index === 4294967295) observation.index = index; + if (observation.routing_observation_slot === 4294967295) observation.routing_observation_slot = index; return false; } if ((n.flags & 256) === 0) return false; @@ -197,7 +197,7 @@ function nscvWidgetRouting(request: Uint8Array): Uint8Array { const lo = wire.getUint32(16, true), hi = wire.getUint32(20, true), x = wire.getFloat32(24, true), y = wire.getFloat32(28, true); const lookup = (): number => { if (lo === 0 && hi === 0) return missing; for (let i = 0; i < count; i++) if (nodes[i]!.lo === lo && nodes[i]!.hi === hi) return i; return missing; }; let target = missing, press = missing, status = 0, keepHit = false; - const observation: NscRouteObservation = { index: missing }; + const observation: NscRouteObservation = { routing_observation_slot: missing }; const entries: NscRouteEntry[] = []; if (op <= 1) target = nscvRouteHitTree(nodes, x, y, layers, op === 1); else if (op === 2) target = nscvRoutePress(nodes, subject); @@ -239,9 +239,9 @@ function nscvWidgetRouting(request: Uint8Array): Uint8Array { if (target === missing) for (let i = count - 1; i >= (current === missing ? 0 : current + 1); i--) if (nscvRouteFocus(nodes, i, false, observation)) { target = i; break; } } } else if ((op === 12 || op === 13) && subject < count && nscvRouteFocus(nodes, subject, op === 13, observation)) target = subject; - if (observation.index !== missing) { + if (observation.routing_observation_slot !== missing) { const pending = new Uint8Array(24), out = new DataView(pending.buffer); - pending.set([1, op, 3, 0]); out.setUint32(4, observation.index, true); out.setUint32(8, missing, true); + pending.set([1, op, 3, 0]); out.setUint32(4, observation.routing_observation_slot, true); out.setUint32(8, missing, true); return pending; } if (op >= 6 && op <= 9 && target < count) { From 022933450a94f6c4a25baa1bc2229f79369adfe5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 6 Oct 2026 22:42:08 -0500 Subject: [PATCH 4/4] Track portable policy inputs in app builds - Rebuild compiled app facades when portable framework sources change. - Keep incremental app builds and reference tests on the same policy inputs. --- build.zig | 1 + build/app.zig | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/build.zig b/build.zig index 8f81ff252..c6d0fde01 100644 --- a/build.zig +++ b/build.zig @@ -5380,6 +5380,7 @@ fn externalCoreFixtureModule( stage_run.addFileInput(b.path("packages/core/src/widget_motion.ts")); stage_run.addFileInput(b.path("packages/core/src/component_composition.ts")); stage_run.addFileInput(b.path("packages/core/src/code_content.ts")); + stage_run.addFileInput(b.path("packages/core/src/chart_content.ts")); stage_run.addFileInput(b.path("packages/core/src/markdown_content.ts")); stage_run.addFileInput(b.path("packages/core/src/widget_audits.ts")); stage_run.addFileInput(b.path("packages/core/src/widget_routing.ts")); diff --git a/build/app.zig b/build/app.zig index 8bff04eb2..735a10e48 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1242,6 +1242,15 @@ fn tsCoreStage( stage_run.addFileInput(dep.path("packages/core/src/view_components.ts")); stage_run.addFileInput(dep.path("packages/core/src/runtime_policy.ts")); stage_run.addFileInput(dep.path("packages/core/src/control_appearance.ts")); + stage_run.addFileInput(dep.path("packages/core/src/component_construction.ts")); + stage_run.addFileInput(dep.path("packages/core/src/widget_motion.ts")); + stage_run.addFileInput(dep.path("packages/core/src/component_composition.ts")); + stage_run.addFileInput(dep.path("packages/core/src/code_content.ts")); + stage_run.addFileInput(dep.path("packages/core/src/chart_content.ts")); + stage_run.addFileInput(dep.path("packages/core/src/markdown_content.ts")); + stage_run.addFileInput(dep.path("packages/core/src/widget_audits.ts")); + stage_run.addFileInput(dep.path("packages/core/src/widget_routing.ts")); + stage_run.addFileInput(dep.path("packages/core/src/stream_policy.ts")); if (typescript_view) { stage_run.addArg("--view-markup"); stage_run.addFileArg(b.path(appPath(b, app_root, "src/app.native")));